commit 16499fb7cd6d4fd6892d56bfb53d5ed637991fce Author: Adolfo Santiago Date: Tue Aug 24 11:09:11 2021 +0200 Init commit diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..62eb927 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: tusky diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80cd3ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +app/release \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..39b4640 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +language: android +dist: xenial +# Disable building on tags +if: tag IS blank +android: + components: + - android-29 + - build-tools-28.0.3 +before_script: + - yes | sdkmanager "ndk-bundle" + - export ANDROID_NDK_ROOT=$ANDROID_HOME/ndk-bundle + - export ANDROID_NDK_HOME=$ANDROID_NDK_ROOT + - sed -i "s/blue/\/\/blue/" app/build.gradle + - sed -i "s/\/\/abortOnError/abortOnError/" app/build.gradle +# - sed -i "s/debug {}//" app/build.gradle +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.android/build-cache +script: + - ./gradlew build +after_success: + - $ANDROID_HOME/build-tools/28.0.3/apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass "pass:android" --key-pass "pass:android" --in $(find -name "*-green-debug.apk") --out husky-green-debug.apk + - $ANDROID_HOME/build-tools/28.0.3/apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass "pass:android" --key-pass "pass:android" --in $(find -name "*-green-release-unsigned.apk") --out husky-green-release.apk + - wget https://raw.githubusercontent.com/FWGS/uploadtool/master/upload.sh + - chmod +x upload.sh + - GITHUB_TOKEN=$GH_TOKEN ./upload.sh husky-green-debug.apk husky-green-release.apk +branches: + except: +# Do not build tags that we create when we upload to GitHub Releases + - /^(?i:continuous)/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0e4f81a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +## Getting Started +1. Fork the repository on the GitHub page by clicking the Fork button. This makes a fork of the project under your GitHub account. +2. Clone your fork to your machine. ```git clone https://github.com//Tusky``` +3. Create a new branch named after your change. ```git checkout -b your-change-name``` (```checkout``` switches to a branch, ```-b``` specifies that the branch is a new one) + +## Making Changes + +### Text +All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```. + +### Translation +Translations are done through https://weblate.tusky.app/projects/tusky/tusky/ . +To add a new language, clic on the 'Start a new translation' button on at the bottom of the page. + +### Kotlin +This project is in the process of migrating to Kotlin, we prefer new code to be written in Kotlin. We try to follow the [Kotlin Style Guide](https://android.github.io/kotlin-guides/style.html) and make use of the [Kotlin Android Extensions](https://kotlinlang.org/docs/tutorials/android-plugin.html). + +### Java +Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. + +### Visuals +There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. For icons and drawables, use a white drawable and tint it at runtime using ```ThemeUtils``` and specify an attribute that references different colours depending on the theme. + +### Saving +Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands: +``` +git add . +git commit -m "Describe the changes in this commit here." +``` + +## Submitting Your Changes +1. Make sure your branch is up-to-date with the ```master``` branch. Run: +``` +git fetch +git rebase origin/master +``` +It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on master to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```. + +2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```. +3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```master``` as the base branch. +4. Wait for feedback on your pull request and be ready to make some changes + +If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..be11d03 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,9 @@ + + +* * * * +- Husky Version: +- Android Version: +- Android Device: +- Fediverse instance (if applicable): + +- [ ] I searched or browsed the repo’s other issues to ensure this is not a duplicate. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + 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 + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6a327f --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Husky +[![Build Status](https://api.travis-ci.org/FWGS/Husky.svg?branch=develop)](https://travis-ci.org/FWGS/Husky)\ +[![Download F-Droid](https://img.shields.io/badge/download-fdroid-blue)](https://f-droid.org/repository/browse/?fdid=su.xash.husky)\ +[![Download Google Play](https://img.shields.io/badge/download-googleplay-blue)](https://play.google.com/store/apps/details?id=su.xash.husky)\ +[![Download Testing](https://img.shields.io/badge/downloads-testing-green)](https://github.com/FWGS/Husky/releases/tag/continuous) + +![icon](https://git.mentality.rip/FWGS/Husky/raw/branch/develop/assets/splash.xcf) + +Husky is a fork of [Tusky](https://github.com/tuskyapp/Tusky) that aimed to support [Pleroma's Mastodon API extensions](https://git.pleroma.social/pleroma/pleroma/blob/develop/docs/API/differences_in_mastoapi_responses.md) and some ideas that I may come up with. + +Tusky is quote, unquote, `... a beautiful Android client for [Mastodon](https://github.com/tootsuite/mastodon). Mastodon is an ActivityPub federated social network. That means no single entity controls the whole network, rather, like e-mail, volunteers and organisations operate their own independent servers, users from which can all interact with each other seamlessly.` + +## Main changes so far +- Emoji reactions support +- Removed attachment limits for Pleroma +- Support for attaching anything on Pleroma +- Support for changing OAuth application name +- Markdown support with WYSIWYG editor +- Support for extended accounts fields, so you can see who is admin or moderator on your instance +- Subscribing support to annoy you with incoming notification from every post (upstreamed to Tusky) +- Support for seen notifications to less annoy you +- "Reply to" feature that allows to jump to replied status, useful for hellthreading ;) +- Bigger emojis! +- "Preview" feature on Pleroma + +### Support + +If you have any bug reports, feature requests or questions please open an issue or send us a post at [husky@huskyapp.dev](https://huskyapp.dev/users/husky)! + +For translating Tusky into your language, visit https://weblate.tusky.app. +For translating Husky, visit https://l10n.mentality.rip. + +### Head of development + +This app was developed by [Vavassor@mastodon.social](https://mastodon.social/@Vavassor). +The Tusky's maintainer is [ConnyDuck@chaos.social](https://chaos.social/@ConnyDuck). +The fork main developer is [a1batross@expired.mentality.rip](https://expired.mentality.rip/users/a1batross). diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3f1ce47 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +app-release.apk diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..eb6f662 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,206 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' + +apply from: "../instance-build.gradle" + +def getGitSha = { + def stdout = new ByteArrayOutputStream() + exec { + commandLine 'git', 'rev-parse', '--short', 'HEAD' + standardOutput = stdout + } + return stdout.toString().trim() +} + +def buildnum = { + def today = new Date() + def epoch = new Date(119, 11, 8) // first Husky commit was 20191208 + return today - epoch +} + +android { + compileSdkVersion 29 + // ndkVersion "20.1.5948944" + defaultConfig { + applicationId APP_ID + minSdkVersion 21 + targetSdkVersion 29 + versionCode buildnum() + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + + resValue "string", "app_name", APP_NAME + + buildConfigField("String", "APPLICATION_NAME", "\"$APP_NAME\"") + buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"") + buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"") + buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"") + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + } + } + } + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles 'proguard-rules.pro' + } + debug {} + } + + flavorDimensions "husky", "color" + productFlavors { + husky { dimension "husky" } + + blue { dimension "color" } + green { + dimension "color" + resValue "string", "app_name", APP_NAME + " Test" + applicationIdSuffix ".test" + versionNameSuffix "-" + getGitSha() + } + } + + lintOptions { + //abortOnError false + disable 'MissingTranslation' + disable 'ExtraTranslation' + disable 'AppCompatCustomView' // I don't care about AppCompat bloat + disable 'UseRequireInsteadOfGet' + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + androidExtensions { + experimental = true + } + buildFeatures { + viewBinding true + } + testOptions { + unitTests { + returnDefaultValues = true + includeAndroidResources = true + } + } + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } + + packagingOptions { + // Exclude unneeded files added by libraries + exclude 'LICENSE_OFL' + exclude 'LICENSE_UNICODE' + } + bundle { + language { + // bundle all languages in every apk so the dynamic language switching works + enableSplit = false + } + } +} + +project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +ext.lifecycleVersion = "2.2.0" +ext.roomVersion = '2.2.5' +ext.retrofitVersion = '2.9.0' +ext.okhttpVersion = '4.9.0' +ext.glideVersion = '4.11.0' +ext.daggerVersion = '2.30.1' +ext.materialdrawerVersion = '8.2.0' + +// if libraries are changed here, they should also be changed in LicenseActivity +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + implementation "androidx.core:core-ktx:1.3.2" + implementation "androidx.appcompat:appcompat:1.2.0" + implementation "androidx.fragment:fragment-ktx:1.2.5" + implementation "androidx.browser:browser:1.3.0" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" + implementation "androidx.recyclerview:recyclerview:1.1.0" + implementation "androidx.exifinterface:exifinterface:1.3.2" + implementation "androidx.cardview:cardview:1.0.0" + implementation "androidx.preference:preference-ktx:1.1.1" + implementation "androidx.sharetarget:sharetarget:1.0.0" + implementation "androidx.emoji:emoji:1.1.0" + implementation "androidx.emoji:emoji-appcompat:1.1.0" + implementation "androidx.emoji:emoji-bundled:1.1.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" + implementation "androidx.constraintlayout:constraintlayout:2.0.4" + implementation "androidx.paging:paging-runtime-ktx:2.1.2" + implementation "androidx.viewpager2:viewpager2:1.0.0" + implementation "androidx.work:work-runtime:2.4.0" + implementation "androidx.room:room-runtime:$roomVersion" + implementation "androidx.room:room-rxjava2:$roomVersion" + kapt "androidx.room:room-compiler:$roomVersion" + + implementation "com.google.android.material:material:1.2.1" + implementation 'com.google.android:flexbox:2.0.1' + + implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" + implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" + implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" + + implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" + implementation "com.squareup.okhttp3:okhttp-brotli:$okhttpVersion" + + implementation "org.conscrypt:conscrypt-android:2.5.1" + + implementation "com.github.bumptech.glide:glide:$glideVersion" + implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" + kapt "com.github.bumptech.glide:compiler:$glideVersion" + + implementation "io.reactivex.rxjava2:rxjava:2.2.20" + implementation "io.reactivex.rxjava2:rxandroid:2.1.1" + implementation "io.reactivex.rxjava2:rxkotlin:2.4.0" + + implementation "com.uber.autodispose:autodispose-android-archcomponents:1.4.0" + implementation "com.uber.autodispose:autodispose:1.4.0" + + implementation "com.google.dagger:dagger:$daggerVersion" + kapt "com.google.dagger:dagger-compiler:$daggerVersion" + implementation "com.google.dagger:dagger-android:$daggerVersion" + implementation "com.google.dagger:dagger-android-support:$daggerVersion" + kapt "com.google.dagger:dagger-android-processor:$daggerVersion" + + implementation "com.github.connyduck:sparkbutton:4.1.0" + + implementation 'com.github.piasy:BigImageViewer:1.7.0' + implementation 'com.github.piasy:GlideImageLoader:1.7.0' + implementation 'com.github.piasy:GlideImageViewFactory:1.7.0' + + implementation "com.mikepenz:materialdrawer:$materialdrawerVersion" + implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" + implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar' + + implementation "com.theartofdev.edmodo:android-image-cropper:2.8.0" + + implementation "de.c1710:filemojicompat:1.0.17" + implementation 'com.github.Tunous:MarkdownEdit:1.0.0' + + testImplementation "androidx.test.ext:junit:1.1.2" + testImplementation "org.robolectric:robolectric:4.4" + testImplementation "org.mockito:mockito-inline:3.6.28" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + + androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" + androidTestImplementation "androidx.room:room-testing:$roomVersion" + androidTestImplementation "androidx.test.ext:junit:1.1.2" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a05994a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,71 @@ +# GENERAL OPTIONS + +# turn on all optimizations except those that are known to cause problems on Android +-optimizations !code/simplification/cast,!field/*,!class/merging/* +-optimizationpasses 6 +-allowaccessmodification +-dontpreverify + +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses +-keepattributes *Annotation* + +# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native +-keepclasseswithmembernames class * { + native ; +} +# keep setters in Views so that animations can still work. +# see http://proguard.sourceforge.net/manual/examples.html#beans +-keepclassmembers public class * extends android.view.View { + void set*(***); + *** get*(); +} +# We want to keep methods in Activity that could be used in the XML attribute onClick +-keepclassmembers class * extends android.app.Activity { + public void *(android.view.View); +} +# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +# TUSKY SPECIFIC OPTIONS + +# keep members of our model classes, they are used in json de/serialization +-keepclassmembers class com.keylesspalace.tusky.entity.* { *; } + +-keep public enum com.keylesspalace.tusky.entity.*$** { + **[] $VALUES; + public *; +} + +-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type { + public *; +} + +# preserve line numbers for crash reporting +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# remove all logging from production apk +-assumenosideeffects class android.util.Log { + public static *** getStackTraceString(...); + public static *** d(...); + public static *** w(...); + public static *** v(...); + public static *** i(...); +} +-assumenosideeffects class java.lang.String { + public static java.lang.String format(...); +} + +# remove some kotlin overhead +-assumenosideeffects class kotlin.jvm.internal.Intrinsics { + static void checkParameterIsNotNull(java.lang.Object, java.lang.String); + static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); + static void throwUninitializedPropertyAccessException(java.lang.String); +} diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/10.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/10.json new file mode 100644 index 0000000..f1f83ff --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/10.json @@ -0,0 +1,275 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "69e310ef98c0f305934d25e763ee0140", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"69e310ef98c0f305934d25e763ee0140\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json new file mode 100644 index 0000000..fe3fb45 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json @@ -0,0 +1,515 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "f5e93302cf53d4250e455b701bea102f", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `instance` TEXT NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"f5e93302cf53d4250e455b701bea102f\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json new file mode 100644 index 0000000..c217590 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json @@ -0,0 +1,668 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "d4d3d4c683ab7f681459b9edab92301c", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `instance` TEXT NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"d4d3d4c683ab7f681459b9edab92301c\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/13.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/13.json new file mode 100644 index 0000000..ba7e57b --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/13.json @@ -0,0 +1,656 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "9a63a3ab2c05004022c350aab0e472c0", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"9a63a3ab2c05004022c350aab0e472c0\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json new file mode 100644 index 0000000..85c8028 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json @@ -0,0 +1,662 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "b9ca62605345d229ced2bb0c1f2db79b", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"b9ca62605345d229ced2bb0c1f2db79b\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json new file mode 100644 index 0000000..eb57584 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json @@ -0,0 +1,674 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "6a01315ce9f7d402cb61e611140e3c0a", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"6a01315ce9f7d402cb61e611140e3c0a\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/16.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/16.json new file mode 100644 index 0000000..a4c4482 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/16.json @@ -0,0 +1,680 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "821df8c72aa78a288b4ae9fe2df21dda", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"821df8c72aa78a288b4ae9fe2df21dda\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/17.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/17.json new file mode 100644 index 0000000..15a7b5e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/17.json @@ -0,0 +1,686 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "4e6bfccf6ec0812dc0bc58d5bc8cf556", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"4e6bfccf6ec0812dc0bc58d5bc8cf556\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/18.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/18.json new file mode 100644 index 0000000..0319e67 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/18.json @@ -0,0 +1,693 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "33d7d9b8ba14c87b96ce795c337bfc57", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '33d7d9b8ba14c87b96ce795c337bfc57')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json new file mode 100644 index 0000000..0d62b12 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json @@ -0,0 +1,711 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "84ebd39cba4d6749251d330851b70e36", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '84ebd39cba4d6749251d330851b70e36')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/20.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/20.json new file mode 100644 index 0000000..ca6e30a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/20.json @@ -0,0 +1,723 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "611700a54bdc155d6bc9d87b8b2af2aa", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '611700a54bdc155d6bc9d87b8b2af2aa')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json new file mode 100644 index 0000000..f1b7406 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json @@ -0,0 +1,735 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "f0db2430c0e36a26264ffb4ac5bb20de", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f0db2430c0e36a26264ffb4ac5bb20de')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/22.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/22.json new file mode 100644 index 0000000..a30ff7b --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/22.json @@ -0,0 +1,741 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "8d27bf5cb75301211453986dccaf2c57", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEmojiReactions", + "columnName": "notificationsEmojiReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8d27bf5cb75301211453986dccaf2c57')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/23.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/23.json new file mode 100644 index 0000000..771f177 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/23.json @@ -0,0 +1,753 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "0cb482507cdcf5628ae028242c3b74bb", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formattingSyntax", + "columnName": "formattingSyntax", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEmojiReactions", + "columnName": "notificationsEmojiReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultFormattingSyntax", + "columnName": "defaultFormattingSyntax", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0cb482507cdcf5628ae028242c3b74bb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/24.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/24.json new file mode 100644 index 0000000..7c98d37 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/24.json @@ -0,0 +1,759 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "90a7a3288df43c1f177c54c013629b0f", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formattingSyntax", + "columnName": "formattingSyntax", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEmojiReactions", + "columnName": "notificationsEmojiReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultFormattingSyntax", + "columnName": "defaultFormattingSyntax", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90a7a3288df43c1f177c54c013629b0f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json new file mode 100644 index 0000000..b7213d1 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json @@ -0,0 +1,897 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "0a5f8f196d357a01b8b571098ea32431", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formattingSyntax", + "columnName": "formattingSyntax", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsStreamingEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationsChatMessages` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsStreamingEnabled", + "columnName": "notificationsStreamingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEmojiReactions", + "columnName": "notificationsEmojiReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsChatMessages", + "columnName": "notificationsChatMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultFormattingSyntax", + "columnName": "defaultFormattingSyntax", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, `chatLimit` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chatLimit", + "columnName": "chatLimit", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `pleroma` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pleroma", + "columnName": "pleroma", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ChatEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastMessageId` TEXT, PRIMARY KEY(`localId`, `chatId`))", + "fields": [ + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "localId", + "chatId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ChatMessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))", + "fields": [ + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachment", + "columnName": "attachment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0a5f8f196d357a01b8b571098ea32431')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json new file mode 100644 index 0000000..ed54aec --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json @@ -0,0 +1,909 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "f6370dbef6f97c3b6de019eb14c7c461", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formattingSyntax", + "columnName": "formattingSyntax", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsStreamingEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationsChatMessages` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsMove` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsStreamingEnabled", + "columnName": "notificationsStreamingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEmojiReactions", + "columnName": "notificationsEmojiReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsChatMessages", + "columnName": "notificationsChatMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMove", + "columnName": "notificationsMove", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultFormattingSyntax", + "columnName": "defaultFormattingSyntax", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, `chatLimit` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chatLimit", + "columnName": "chatLimit", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `pleroma` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pleroma", + "columnName": "pleroma", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ChatEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastMessageId` TEXT, PRIMARY KEY(`localId`, `chatId`))", + "fields": [ + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "localId", + "chatId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ChatMessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))", + "fields": [ + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachment", + "columnName": "attachment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f6370dbef6f97c3b6de019eb14c7c461')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json new file mode 100644 index 0000000..d758b15 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json @@ -0,0 +1,989 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "8977aa85e5ac4f803fe64b7e04ef4eeb", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formattingSyntax", + "columnName": "formattingSyntax", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markdownMode", + "columnName": "markdownMode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formattingSyntax", + "columnName": "formattingSyntax", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsStreamingEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationsChatMessages` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsMove` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsStreamingEnabled", + "columnName": "notificationsStreamingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEmojiReactions", + "columnName": "notificationsEmojiReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsChatMessages", + "columnName": "notificationsChatMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMove", + "columnName": "notificationsMove", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultFormattingSyntax", + "columnName": "defaultFormattingSyntax", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, `chatLimit` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chatLimit", + "columnName": "chatLimit", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `pleroma` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pleroma", + "columnName": "pleroma", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ChatEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastMessageId` TEXT, PRIMARY KEY(`localId`, `chatId`))", + "fields": [ + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "localId", + "chatId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ChatMessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))", + "fields": [ + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachment", + "columnName": "attachment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8977aa85e5ac4f803fe64b7e04ef4eeb')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java new file mode 100644 index 0000000..e69de29 diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt new file mode 100644 index 0000000..9c65aeb --- /dev/null +++ b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt @@ -0,0 +1,64 @@ +package com.keylesspalace.tusky + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.db.AppDatabase +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +const val TEST_DB = "migration_test" + +@RunWith(AndroidJUnit4::class) +class MigrationsTest { + + @JvmField + @Rule + var helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migrateTo11() { + val db = helper.createDatabase(TEST_DB, 10) + + val id = 1 + val domain = "domain.site" + val token = "token" + val active = true + val accountId = "accountId" + val username = "username" + val values = arrayOf(id, domain, token, active, accountId, username, "Display Name", + "https://picture.url", true, true, true, true, true, true, true, + true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, + false, true) + + db.execSQL("INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," + + "`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," + + "`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," + + "`notificationsFavorited`,`notificationSound`,`notificationVibration`," + + "`notificationLight`,`lastNotificationId`,`activeNotifications`,`emojis`," + + "`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," + + "`mediaPreviewEnabled`) " + + "VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + values) + + db.close() + + val newDb = helper.runMigrationsAndValidate(TEST_DB, 11, true, AppDatabase.MIGRATION_10_11) + + val cursor = newDb.query("SELECT * FROM AccountEntity") + cursor.moveToFirst() + assertEquals(id, cursor.getInt(0)) + assertEquals(domain, cursor.getString(1)) + assertEquals(token, cursor.getString(2)) + assertEquals(active, cursor.getInt(3) != 0) + assertEquals(accountId, cursor.getString(4)) + assertEquals(username, cursor.getString(5)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt new file mode 100644 index 0000000..2f891b3 --- /dev/null +++ b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt @@ -0,0 +1,249 @@ +package com.keylesspalace.tusky + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.repository.TimelineRepository +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineDAOTest { + private lateinit var timelineDao: TimelineDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + timelineDao = db.timelineDao() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun insertGetStatus() { + val setOne = makeStatus(statusId = 3) + val setTwo = makeStatus(statusId = 20, reblog = true) + val ignoredOne = makeStatus(statusId = 1) + val ignoredTwo = makeStatus(accountId = 2) + + for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { + timelineDao.insertInTransaction(status, author, reblogger) + } + + val resultsFromDb = timelineDao.getStatusesForAccount(setOne.first.timelineUserId, + maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10) + .blockingGet() + + assertEquals(2, resultsFromDb.size) + for ((set, fromDb) in listOf(setTwo, setOne).zip(resultsFromDb)) { + val (status, author, reblogger) = set + assertEquals(status, fromDb.status) + assertEquals(author, fromDb.account) + assertEquals(reblogger, fromDb.reblogAccount) + } + } + + @Test + fun doNotOverwrite() { + val (status, author) = makeStatus() + timelineDao.insertInTransaction(status, author, null) + + val placeholder = createPlaceholder(status.serverId, status.timelineUserId) + + timelineDao.insertStatusIfNotThere(placeholder) + + val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10) + .blockingGet() + val result = fromDb.first() + + assertEquals(1, fromDb.size) + assertEquals(author, result.account) + assertEquals(status, result.status) + assertNull(result.reblogAccount) + + } + + @Test + fun cleanup() { + val now = System.currentTimeMillis() + val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000 + val oldThisAccount = makeStatus( + statusId = 5, + createdAt = oldDate + ) + val oldAnotherAccount = makeStatus( + statusId = 10, + createdAt = oldDate, + accountId = 2 + ) + val recentThisAccount = makeStatus( + statusId = 30, + createdAt = System.currentTimeMillis() + ) + val recentAnotherAccount = makeStatus( + statusId = 60, + createdAt = System.currentTimeMillis(), + accountId = 2 + ) + + for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) { + timelineDao.insertInTransaction(status, author, reblogAuthor) + } + + timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL) + + assertEquals( + listOf(recentThisAccount), + timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() + .map { it.toTriple() } + ) + + assertEquals( + listOf(recentAnotherAccount), + timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet() + .map { it.toTriple() } + ) + } + + @Test + fun overwriteDeletedStatus() { + + val oldStatuses = listOf( + makeStatus(statusId = 3), + makeStatus(statusId = 2), + makeStatus(statusId = 1) + ) + + timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId) + + for ((status, author, reblogAuthor) in oldStatuses) { + timelineDao.insertInTransaction(status, author, reblogAuthor) + } + + // status 2 gets deleted, newly loaded status contain only 1 + 3 + val newStatuses = listOf( + makeStatus(statusId = 3), + makeStatus(statusId = 1) + ) + + timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) + + for ((status, author, reblogAuthor) in newStatuses) { + timelineDao.insertInTransaction(status, author, reblogAuthor) + } + + //make sure status 2 is no longer in db + + assertEquals( + newStatuses, + timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() + .map { it.toTriple() } + ) + } + + private fun makeStatus( + accountId: Long = 1, + statusId: Long = 10, + reblog: Boolean = false, + createdAt: Long = statusId, + authorServerId: String = "20" + ): Triple { + val author = TimelineAccountEntity( + authorServerId, + accountId, + "localUsername", + "username", + "displayName", + "blah", + "avatar", + "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", + false + ) + + val reblogAuthor = if (reblog) { + TimelineAccountEntity( + "R$authorServerId", + accountId, + "RlocalUsername", + "Rusername", + "RdisplayName", + "Rblah", + "Ravatar", + "[]", + false + ) + } else null + + + val even = accountId % 2 == 0L + val status = TimelineStatusEntity( + serverId = statusId.toString(), + url = "url$statusId", + timelineUserId = accountId, + authorServerId = authorServerId, + inReplyToId = "inReplyToId$statusId", + inReplyToAccountId = "inReplyToAccountId$statusId", + content = "Content!$statusId", + createdAt = createdAt, + emojis = "emojis$statusId", + reblogsCount = 1 * statusId.toInt(), + favouritesCount = 2 * statusId.toInt(), + reblogged = even, + bookmarked = !even, + favourited = even, + sensitive = !even, + spoilerText = "spoier$statusId", + visibility = Status.Visibility.PRIVATE, + attachments = "attachments$accountId", + mentions = "mentions$accountId", + application = "application$accountId", + reblogServerId = if (reblog) (statusId * 100).toString() else null, + reblogAccountId = reblogAuthor?.serverId, + poll = null, + muted = false + ) + return Triple(status, author, reblogAuthor) + } + + private fun createPlaceholder(serverId: String, timelineUserId: Long): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = serverId, + url = null, + timelineUserId = timelineUserId, + authorServerId = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + bookmarked = false, + favourited = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + muted = false + ) + } + + private fun TimelineStatusWithAccount.toTriple() = Triple(status, account, reblogAccount) +} diff --git a/app/src/blue/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/blue/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d4bd0e4 --- /dev/null +++ b/app/src/blue/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/blue/res/mipmap-hdpi/ic_launcher.png b/app/src/blue/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4e05718 Binary files /dev/null and b/app/src/blue/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-hdpi/ic_shortcut_compose.png b/app/src/blue/res/mipmap-hdpi/ic_shortcut_compose.png new file mode 100644 index 0000000..8d8e6d8 Binary files /dev/null and b/app/src/blue/res/mipmap-hdpi/ic_shortcut_compose.png differ diff --git a/app/src/blue/res/mipmap-mdpi/ic_launcher.png b/app/src/blue/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ceafe8f Binary files /dev/null and b/app/src/blue/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-mdpi/ic_shortcut_compose.png b/app/src/blue/res/mipmap-mdpi/ic_shortcut_compose.png new file mode 100644 index 0000000..a97038f Binary files /dev/null and b/app/src/blue/res/mipmap-mdpi/ic_shortcut_compose.png differ diff --git a/app/src/blue/res/mipmap-xhdpi/ic_launcher.png b/app/src/blue/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4dba57c Binary files /dev/null and b/app/src/blue/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-xhdpi/ic_shortcut_compose.png b/app/src/blue/res/mipmap-xhdpi/ic_shortcut_compose.png new file mode 100644 index 0000000..4912075 Binary files /dev/null and b/app/src/blue/res/mipmap-xhdpi/ic_shortcut_compose.png differ diff --git a/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png b/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..1e231b6 Binary files /dev/null and b/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-xxhdpi/ic_shortcut_compose.png b/app/src/blue/res/mipmap-xxhdpi/ic_shortcut_compose.png new file mode 100644 index 0000000..2fccda6 Binary files /dev/null and b/app/src/blue/res/mipmap-xxhdpi/ic_shortcut_compose.png differ diff --git a/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2728f33 Binary files /dev/null and b/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-xxxhdpi/ic_shortcut_compose.png b/app/src/blue/res/mipmap-xxxhdpi/ic_shortcut_compose.png new file mode 100644 index 0000000..21c3a4c Binary files /dev/null and b/app/src/blue/res/mipmap-xxxhdpi/ic_shortcut_compose.png differ diff --git a/app/src/green/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/green/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..cace1c4 --- /dev/null +++ b/app/src/green/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/green/res/mipmap-hdpi/ic_launcher.png b/app/src/green/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..fa827e9 Binary files /dev/null and b/app/src/green/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-mdpi/ic_launcher.png b/app/src/green/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..8c0b5d1 Binary files /dev/null and b/app/src/green/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_launcher.png b/app/src/green/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a4bdd1f Binary files /dev/null and b/app/src/green/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..fa47fdf Binary files /dev/null and b/app/src/green/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2169fe1 Binary files /dev/null and b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/husky/res/values-ar/husky_generated.xml b/app/src/husky/res/values-ar/husky_generated.xml new file mode 100644 index 0000000..1c8b87c --- /dev/null +++ b/app/src/husky/res/values-ar/husky_generated.xml @@ -0,0 +1,56 @@ + + + توسكي %s + + + Tuksy برنامج حر و مفتوح المصدر. مطور تحت رخصة GNU General Public License Version 3. يمكنكم الإطلاع على الرخصة على : https://www.gnu.org/licenses/gpl-3.0.en.html + + + الملف الشخصي لتوسكي + + + إعادة تشغيل توسكي مطلوبة قصد تفعيل التعديلات + + + يحتوي توسكي على شيفرة وأوصول صادرة مِن المشاريع المفتوحة التالية : + + + مدعوم بِـ Husky + + + + + + موقع المشروع :\n + https://huskyapp.dev + + + + + تقارير الأخطاء و طلبات التحسينات على :\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + الولوج إلى ماستدون + + + إضافة حساب ماستدون جديد + + + تُقدّر أدنى فترة لبرمجة النشر في ماستدون بـ 5 دقائق. + + + + + بإمكانك إدخال عنوان أي مثيل خادوم ماستدون هنا. على سبيل المثال shitposter.club أو blob.cat أو expired.mentality.rip أوالإطلاع على لاكتشاف المزيد ! +\n +\n إن كنت لا تملك حسابا بإمكانك إدخال اسم مثيل خادوم تريد الانضمام إليه قصد إنشاء حسابك عليه. +\n +\n نعني بمثيل الخادوم المكان الذي استُضِيف فيه حسابك و يمكنك التواصل مع أصدقائك و متابعيك و كأنكم على موقع واحد و ذلك حتى و إن كانت حساباتهم مُستضافة على مثيلات خوادم أخرى. +\n +\n للمزيد مِن التفاصيل إطّلع على joinmastodon.org. + + + diff --git a/app/src/husky/res/values-ar/strings.xml b/app/src/husky/res/values-ar/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/app/src/husky/res/values-ar/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/husky/res/values-ber/husky_generated.xml b/app/src/husky/res/values-ber/husky_generated.xml new file mode 100644 index 0000000..c0dcd42 --- /dev/null +++ b/app/src/husky/res/values-ber/husky_generated.xml @@ -0,0 +1,14 @@ + + + + + + + ⵇⵇⴻⵏ ⵖⴻⵔ ⵎⴰⵚⵟⵓⴷⵓⵏ + + + ⵔⵏⵓ ⵢⵉⵡⴻⵏ ⵏ ⵓⵎⵉⴹⴰⵏ ⴰⵎⴰⵢⵏⵓⵝ ⵏ ⵎⴰⵚⵟⵓⴷⵓⵏ + + + + diff --git a/app/src/husky/res/values-bn-rBD/husky_generated.xml b/app/src/husky/res/values-bn-rBD/husky_generated.xml new file mode 100644 index 0000000..6976ccc --- /dev/null +++ b/app/src/husky/res/values-bn-rBD/husky_generated.xml @@ -0,0 +1,62 @@ + + + টাস্কি নিম্নলিখিত ওপেন সোর্স প্রকল্প থেকে কোড এবং সম্পদ রয়েছে: + + + এই পরিবর্তনগুলি প্রয়োগ করার জন্য আপনাকে টাস্কি পুনরায় চালু করতে হবে + + + টাস্কির প্রোফাইল + + + টাস্কি মুক্ত এবং ওপেন সোর্স সফ্টওয়্যার। এটি GNU জেনারেল পাবলিক লাইসেন্স সংস্করণ 3 এর অধীনে লাইসেন্সযুক্ত। আপনি এখানে লাইসেন্স দেখতে পারেন: https://www.gnu.org/licenses/gpl-3.0.en.html + + + টাস্কি %s + + + টাস্কি দ্বারা চালিত + + + + + + প্রকল্প ওয়েবসাইট: +\nhttps://huskyapp.dev + + + + + বাগ রিপোর্ট এবং বৈশিষ্ট্য অনুরোধ: +\nhttps://git.mentality.rip/FWGS/Husky/issues + + + + + নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন + + + মাস্টোডনের সঙ্গে লগইন করো + + + মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। + + + + + "কোনও উদাহরণের ঠিকানা বা ডোমেন এখানে প্রবেশ করা যেতে পারে যেমন shitposter.club, blob.cat, expired.mentality.rip, এবং <a href=\"https://fediverse.network/pleroma?count=peers\"> আরও! </a> +\n +\nআপনার যদি এখনো অ্যাকাউন্ট না থাকে তবে আপনি যে ইনস্ট্যান্সটিতে যোগ দিতে চান সেটির নামটি প্রবেশ করতে এবং সেখানে একটি অ্যাকাউন্ট তৈরি করতে পারেন। +\n +\nএকটি ইনস্ট্যান্স একটি একক স্থান যেখানে আপনার অ্যাকাউন্ট হোস্ট করা হয়, তবে আপনি সহজেই যোগাযোগ করতে পারেন এবং অন্যান্য ক্ষেত্রে যেমন আপনি একই সাইটে ছিলেন তা অনুসরণ করতে পারেন। +\n +\nআরো তথ্য <a href=\"https://joinmastodon.org\"> joinmastodon.org </a> এ পাওয়া যেতে পারে। "more! + \n\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to + join and create an account there.\n\nAn instance is a single place where your account is + hosted, but you can easily communicate with and follow folks on other instances as though + you were on the same site. + \n\nMore info can be found at joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-bn-rIN/husky_generated.xml b/app/src/husky/res/values-bn-rIN/husky_generated.xml new file mode 100644 index 0000000..dcfd50a --- /dev/null +++ b/app/src/husky/res/values-bn-rIN/husky_generated.xml @@ -0,0 +1,56 @@ + + + টাস্কি %s + + + টাস্কি মুক্ত এবং ওপেন সোর্স সফ্টওয়্যার। এটি GNU জেনারেল পাবলিক লাইসেন্স সংস্করণ 3 এর অধীনে লাইসেন্সযুক্ত। আপনি এখানে লাইসেন্স দেখতে পারেন: https://www.gnu.org/licenses/gpl-3.0.en.html + + + টাস্কির প্রোফাইল + + + এই পরিবর্তনগুলি প্রয়োগ করার জন্য আপনাকে টাস্কি পুনরায় চালু করতে হবে + + + টাস্কি নিম্নলিখিত ওপেন সোর্স প্রকল্প থেকে কোড এবং সম্পদ রয়েছে: + + + টাস্কি দ্বারা চালিত + + + + + + প্রকল্প ওয়েবসাইট: +\nhttps://huskyapp.dev + + + + + বাগ রিপোর্ট এবং বৈশিষ্ট্য অনুরোধ: +\nhttps://git.mentality.rip/FWGS/Husky/issues + + + + + মাস্টোডনের সঙ্গে লগইন করো + + + নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন + + + মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। + + + + + কোনও উদাহরণের ঠিকানা বা ডোমেন এখানে প্রবেশ করা যেতে পারে যেমন shitposter.club, blob.cat, expired.mentality.rip, এবং আরও! +\n +\nআপনার যদি এখনো অ্যাকাউন্ট না থাকে তবে আপনি যে ইনস্ট্যান্সটিতে যোগ দিতে চান সেটির নামটি প্রবেশ করতে এবং সেখানে একটি অ্যাকাউন্ট তৈরি করতে পারেন। +\n +\nএকটি ইনস্ট্যান্স একটি একক স্থান যেখানে আপনার অ্যাকাউন্ট হোস্ট করা হয়, তবে আপনি সহজেই যোগাযোগ করতে পারেন এবং অন্যান্য ক্ষেত্রে যেমন আপনি একই সাইটে ছিলেন তা অনুসরণ করতে পারেন। +\n +\nআরো তথ্য joinmastodon.org এ পাওয়া যেতে পারে। + + + diff --git a/app/src/husky/res/values-ca/husky_generated.xml b/app/src/husky/res/values-ca/husky_generated.xml new file mode 100644 index 0000000..7153457 --- /dev/null +++ b/app/src/husky/res/values-ca/husky_generated.xml @@ -0,0 +1,63 @@ + + + Husky %s + + + Husky és programari gratuït, lliure i de codi obert. + Està llicenciat en els termes de la Llicència Pública General GNU versió 3. + Podeu trobar les llicència aquí: https://www.gnu.org/licenses/gpl-3.0.ca.html + + + Perfil del Husky + + + Has de reiniciar l\'aplicació per tal d\'aplicar aquests canvis + + + Husky conté codi i recursos dels següents projectes: + + + Desenvolupat per Husky + + + + + + + Lloc web del projecte:\n + https://huskyapp.dev + + + + + + + Informes d\'errors i peticions de funcionalitats:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Inicia sessió amb Pleroma + + + Afegir un compte de Pleromat + + + L\'interval mínim de planificació a Pleroma és de 5 minuts. + + + + + Aquí pots introduir l\'adreça o domini de qualsevol instància, + com ara mastodont.cat, shitposter.club, blob.cat o + molts més! + \n\nSi encara no tens cap copte, pots introduir el nom de la instància on t\'agradaria + unir-te i crear un compte allà.\n\nUna instànica és un únic lloc on el teu compte s\'hostatja + , però pots comunicar-te fàcilment i seguir amics d\'altres instàncies com si fossiu en el mateix lloc. + \n\nTens més informació a joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-ca/strings.xml b/app/src/husky/res/values-ca/strings.xml new file mode 100644 index 0000000..d99794f --- /dev/null +++ b/app/src/husky/res/values-ca/strings.xml @@ -0,0 +1,29 @@ + + + + Respondre a + + Reaccionar + Suprimir reacció + Qui va reaccionar + Activa %s + Desactiva %s + + %s va reaccionar amb + + Nom de l\'aplicació + Pàgina web de l\'aplicació + + Administrador/a + Moderador/a + + L\'arxiu és massa gran + + %s va reaccionar amb %s a la teva publicació + Reaccions + Notificacions sobre noves reaccions + + Sintaxi de format per defecte(si l\'instancia és compatible) + els usuaris poden reaccionar a les meves publicacions + Ocultar usuaris silenciats + diff --git a/app/src/husky/res/values-ckb/husky_generated.xml b/app/src/husky/res/values-ckb/husky_generated.xml new file mode 100644 index 0000000..9c7dda1 --- /dev/null +++ b/app/src/husky/res/values-ckb/husky_generated.xml @@ -0,0 +1,56 @@ + + + تاسکی کۆد و سەرمایەکانی تێدایە لەم پڕۆژە کراوەی سەرچاوە: + + + تۆ پێویستە توسکی دەستپێبکەیتەوە بۆ ئەوەی ئەم گۆڕانکاریانە جێبەجێ بکەیت + + + پرۆفایلی تاسکی + + + توسکی سۆفتوێری ئازاد و سەرچاوەی کراوەیە مۆڵەتدراوە بە پێ نامەی گشتی GNU Public Version 3. دەتوانیت لێرە مۆڵەتەکە نیشان بدەی: https://www.gnu.org/licenses/gpl-3.0.en.html + + + لەلایەن تاسکیەوە دەست کراوە بە + + + توسکی %s + + + + + + وێبسایتی پڕۆژە: +\nhttps://huskyapp.dev + + + + + ڕاپۆرتەکانی هەڵەکان و داواکاریەکانی تایبەتمەندی: +\nhttps://git.mentality.rip/FWGS/Husky/issues + + + + + چوونەژوورەوە لەگەڵ ماستۆدۆن + + + ماستۆدۆن کەمترین ماوەی خشتەی هەیە لە ٥ خولەک. + + + زیادکردنی ئەژمێری ماتۆدۆنی نوێ + + + + + ناونیشان یان دۆمەینی هەر نمونەیەک دەکرێت لێرە تێبنووسرێت، وەک فرەتر! +\n +\nئەگەر هێشتا ئەژمێرێکت نیە، دەتوانیت ناوی ئەو نمونەیە داخڵ بکەیت کە دەتەوێت بیبەستیت و ئەژمێرێک دروست بکەیت لەوێ. +\n +\nنموونەیەک تاکە شوێنە کە ئەژمێرەکەت میوانداری کراوە، بەڵام دەتوانیت بە ئاسانی پەیوەندی لەگەڵ بکەیت و دوای ئەو خەڵکانە بکەویت لە نمونەکانی تر وەک ئەوەی تۆ لە هەمان سایت دابیت. +\n +\nزانیاری زیاتر دەتوانرێت بدۆزرێتەوە لە joinmastodon.org. + + + diff --git a/app/src/husky/res/values-cs/husky_generated.xml b/app/src/husky/res/values-cs/husky_generated.xml new file mode 100644 index 0000000..3919c36 --- /dev/null +++ b/app/src/husky/res/values-cs/husky_generated.xml @@ -0,0 +1,62 @@ + + + Husky %s + + + Husky je svobodný a otevřený software. + Je dostupný pod licencí GNU General Public License, verze 3. + Licenci můžete zobrazit zde: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Profil aplikace Husky + + + Pro použití těchto změn musíte restartovat aplikaci Husky + + + Husky obsahuje kód a zdroje z následujících otevřených projektů: + + + Powered by Husky + + + + + + Webová stránka projektu:\n + https://huskyapp.dev + + + + + + Hlášení chyb a návrhy na nové vlastnosti:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Přihlásit účtem Pleroma + + + Přidat nový účet Pleroma + + + Pleroma neumožňuje pracovat s intervalem menším než 5 minut. + + + + + Sem může být zadána adresa či doména jakéhokoliv + serveru, například shitposter.club, blob.cat, expired.mentality.rip + a další! + \n\nPokud ještě nemáte účet, můžete zadat název instance, ke které se chcete + připojit, a vytvořit si tam účet.\n\nServer je jedno místo, kde je hostován váš + účet, můžete však jednoduše komunikovat a sledovat lidi na jiných serverech, jako by + byli na stejné stránce. + \n\nDalší informace mohou být nalezeny na stránce joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-cy/husky_generated.xml b/app/src/husky/res/values-cy/husky_generated.xml new file mode 100644 index 0000000..db161f8 --- /dev/null +++ b/app/src/husky/res/values-cy/husky_generated.xml @@ -0,0 +1,53 @@ + + + Mae Husky yn feddalwedd ffynhonnell agored barn rydd. + Fe\'i trwyddedir dan Drwydded Gyhoeddus Gyffredinol GNU Fersiwn 3. + Gallwch weld y drwydded yma: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Proffil Husky + + + Bydd angen ailddechrau Husky i roi\'r newidiadau ar waith + + + Mae gan Husky god ac asedau o\'r prosiectau ffynhonnell agored canlynol: + + + + + + Gwefan y prosiect:\n + https://huskyapp.dev + + + + + + Adrodd byg & ceisiadau nodwedd:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Mewngofnodi â Pleroma + + + Ychwanegu cyfrif Pleroma newydd + + + + + Gallwch nodi cyfeiriad neu barth unrhyw achos + yma, fel shitposter.club, twt.cymru, expired.mentality.rip, a + mwy! + \n\n Os nad oes gennych gyfrif, gallwch nodi enw\'r achos yr hoffech ymuno + Ag ef a chreu cyfrif yno.\n\nAchos yw un lle yn lle mae\'ch cyfrif wedi\'i + gynnal, ond gallwch yn hawdd gyfathrebu â phobl a\'u dilyn ar achosion eraill fel petasech chi + ar yr un safle. + \n\nRhagor o wybodaeth yn joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-de/husky_generated.xml b/app/src/husky/res/values-de/husky_generated.xml new file mode 100644 index 0000000..31f7b3f --- /dev/null +++ b/app/src/husky/res/values-de/husky_generated.xml @@ -0,0 +1,59 @@ + + + Husky ist freie und quelloffene Software. Es ist lizenziert unter der GNU General Public License Version 3. Du kannst dir die Lizenz hier anschauen: https://www.gnu.org/licenses/gpl-3.0.de.html + + + Huskys Profil + + + Du musst Husky neustarten um die Änderungen anzuwenden + + + Husky enthält Code und Inhalte von den folgenden Open-Source-Projekten: + + + test %s + + + Angetrieben durch Husky + + + + + + Website des Projekts: +\n https://huskyapp.dev + + + + + Fehlermeldungen & Verbesserungsvorschläge:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Anmelden mit Pleroma + + + Neues Pleroma-Konto hinzufügen + + + Das Datum des geplanten Toots muss mindestens 5 Minuten in der Zukunft liegen. + + + + + Die Adresse einer Instanz oder Domain kann + hier eingegeben werden, wie z.B. shitposter.club, blob.cat, expired.mentality.rip, und + mehr! + \n\nWenn du bis jetzt kein Konto hast, kannst du hier den Namen einer Instanz eingeben + und dort ein Konto einrichten.\n\nEine Instanz ist ein einziger Ort, wo dein Konto + gehostet ist, aber du kannst dennoch mit anderen Leuten reden und mit ihnen interagieren, als + wärt ihr alle auf einer Webseite. + \n\nWeitere Informationen gibt es auf joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-de/strings.xml b/app/src/husky/res/values-de/strings.xml new file mode 100644 index 0000000..8cfaca5 --- /dev/null +++ b/app/src/husky/res/values-de/strings.xml @@ -0,0 +1,69 @@ + + + %s deaktivieren + Reagieren + Reaktion entfernen + Wer hat reagiert + %s-Reaktionen von + Programmname + Administrator + Dateigröße über dem Limit der Instanz + %s aktivieren + Programmwebseite + Moderator + %s hat mit %s auf deinen Post reagiert + Emojireaktionen + Benachrictigungen über neue Emojireaktionen + Standardformatierungssyntax (wenn von der Instanz unterstützt) + Ignorierte Nutzer verstecken + Reaktionen auf meine Nachrichten + Antwort auf + Sticker + Große eigene Emoji aktivieren + Experimentelle Pleroma-FE Sticker aktiveren (wenn verfügbar) + POSTEN + POSTEN! + Beitragssichtbarkeit + Wiederholen + Widerholungen verbergen + Wiederholungen anzeigen + Wiederholungsauthor anzeigen + Wiederholungen anzeigen + Beitrag planen + Es passierte ein Fehler bei dem Empfang des Stickers + Beitrags-URL teilen mit… + Beitrag teilen mit… + Beitrag wird gesendet… + Fehler beim Senden des Beitrages + Beiträge werden gesendet + Eine Kopier des Beitrages wurde in den Entwurfen gesichert + Inhalt des Beitrages teilen + Wiederholung entfernen + Geplante Beiträe + Löschen und Beitrag neu verfassen\? + Benachrichtigungen, wenn deine Beiträge wiederholt werden + %s wiederholte + Beitrag verfassen + Wiederholung entfernen + Beitrag öffnen + Für originale Audienz wiederholen + Wiederholt + Diesen Beitrag löschen\? + Fehler beim Senden des Beitrages. + %s hat deinen Beitrag wiederholt + %s hat deinen Beitrag favorisiert + Wiederholt + Benachrichigungen, wenn deine Beiträge favorisiert wurden + Bestätigung vor dem Löschen anzeigen + Meine Beiträge wurden wiederholt + Wiederholungen anzeigen + Beiträge mit sensiblen Inhalten immer anzeigen + + %s wiederholt + %s wiederholten + + Link zum Beitrag teilen + Geplante Beiträge + Wiederholt von + Beitrag + \ No newline at end of file diff --git a/app/src/husky/res/values-en-rAU/husky_generated.xml b/app/src/husky/res/values-en-rAU/husky_generated.xml new file mode 100644 index 0000000..0b7834d --- /dev/null +++ b/app/src/husky/res/values-en-rAU/husky_generated.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/husky/res/values-en-rGB/husky_generated.xml b/app/src/husky/res/values-en-rGB/husky_generated.xml new file mode 100644 index 0000000..0b7834d --- /dev/null +++ b/app/src/husky/res/values-en-rGB/husky_generated.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/husky/res/values-en-rGB/strings.xml b/app/src/husky/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..5a76942 --- /dev/null +++ b/app/src/husky/res/values-en-rGB/strings.xml @@ -0,0 +1,6 @@ + + + %s favourited your post + Scheduled posts + Reply to + \ No newline at end of file diff --git a/app/src/husky/res/values-eo/husky_generated.xml b/app/src/husky/res/values-eo/husky_generated.xml new file mode 100644 index 0000000..712266d --- /dev/null +++ b/app/src/husky/res/values-eo/husky_generated.xml @@ -0,0 +1,58 @@ + + + Husky %s + + + Husky estas libera kaj malfermitkoda programo. + Ĝi estas publikigita laŭ la permesilo «GNU General Public License Version 3». + Vi povas vidi la permesilon ĉi tie: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Profilo de Husky + + + Vi devos restartigi Husky por apliki ĉi tiujn ŝanĝojn + + + Husky enhavas kodon kaj risurcojn el la sekvantaj malfermitkodaj projetkoj: + + + Funkciigita de Husky + + + + + + Paĝaro de projekto:\n + https://huskyapp.dev + + + + + + Raportoj de cimo kaj petoj de funkcio:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Ensaluti al Pleroma + + + Aldoni novan Pleroma konton + + + Pleroma havas minimuman intervalon de planado de 5 minutoj. + + + + + La adreso aŭ domajno de iu ajn nodo povas esti enmetitaĉi tie, kiel shitposter.club, blob.cat, expired.mentality.rip, kaj + pli! + \n\nSe vi ne ankoraŭ havas konton, vi povas enmeti la nomon de la nodo ke vi volas aliĝi kaj krei konton tie.\n\nNodo estas unika loko kie via konto estas gastigita, sed vi povas facile komuniki kun kaj sekvi homojn ĉe aliaj nodoj kiel vi estus ĉe la sama retejo. + \n\nPliaj informoj troviĝas ĉe joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-es/husky_generated.xml b/app/src/husky/res/values-es/husky_generated.xml new file mode 100644 index 0000000..a43aebd --- /dev/null +++ b/app/src/husky/res/values-es/husky_generated.xml @@ -0,0 +1,62 @@ + + + Husky %s + + + Husky es un software libre y de código abierto. + Está licenciado bajo la licencia \"GNU General Public License Version 3\". + Puedes leer sobre la misma en: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Perfil de Husky + + + Tendrás que reiniciar la aplicación para aplicar estos cambios + + + Husky contiene código y recursos de los siguientes proyectos: + + + Potenciado por Husky + + + + + + Sitio del proyecto:\n + https://huskyapp.dev + + + + + + Reporte de errores y peticiones de características:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Iniciar sesión + + + Añadir cuenta de Pleroma + + + Pleroma tiene un intervalo de programación mínimo de 5 minutos. + + + + + Introduzca aquí dirección o dominio de cualquier instancia, + como shitposter.club, blob.cat, expired.mentality.rip y + más! + \n\nSi todavia no tiene una cuenta, puede indicar el nombre de la instancia a la que quiere + unirse y crear una cuenta allí.\n\nUna instancia es el sitio único donde su cuenta + está alojada, pero puede comunicarse y seguir usuarios de otras instancias como si + estuvieran en la misma. + \n\nPuede consultar más información en joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-es/strings.xml b/app/src/husky/res/values-es/strings.xml new file mode 100644 index 0000000..051b6e9 --- /dev/null +++ b/app/src/husky/res/values-es/strings.xml @@ -0,0 +1,71 @@ + + + Responder a + + Reaccionar + Eliminar la reacción + Quién ha reaccionado + Activar %s + Desactivar %s + %s ha reaccionado con + Nombre de la aplicación + Página web de la aplicación + Administrador/a + + Moderador/a + El archivo es demasiado grande + %s ha reaccionado con %s a tu publicación + Reacciones + Notificaciones acerca de reacciones nuevas + + Sintaxis de formato por defecto(si la instancia los admite) + se pueden enviar reacciones a mis publicaciones + Ocultar a los usuarios silenciados + Se produjo un error al buscar el sticker + Habilitar reacciones experimentales de Pleroma-FE (si está disponible) + ¿Borrar esta publicación\? + Ocultar repeticiones + Repetir para la audencia original + ¿Borrar y editar esta publicación\? + Error al enviar la publicación. + Notificarte cuando tus publicaciones sean marcadas como favoritas + Stickers + Habilitar emojis personalizados más grandes + Visibilidad de las publicaciones + Estados programados + Repetir + Deshacer repeticiones + Mostrar repeticiones + PUBLICADO + Abrir editor de repeticiones + Mostrar repeticiones + Deshacer repetición + Abrir publicación + Crear publicación + Repetido + ¡PUBLICADO! + %s repitió tu publicación + %s le encanto tu publicación + Repeticiones + Notificarte cuando tus publicaciones se repitan + Mostrar mensaje de confimarción antes de repetir + Mis publicaciones están repetidas + Mostrar repeticiones + Siempre expandir publicacines marcadas con avisos de contenido + + %s Repetir + %s Repeticiónes + + Compartir la URL de la publicación con… + Compartir publicación con. . . + Enviando publicación… + Enviando publicaciones + Una copia de la publicación ha sido guardada en tus borradores + Compartir el contenido de la publicación + Compartir link para publicar + %s repitido + Repetido por + Publicar + Ha habido un error al enviar la publicación + Publicaciones programadas + \ No newline at end of file diff --git a/app/src/husky/res/values-eu/husky_generated.xml b/app/src/husky/res/values-eu/husky_generated.xml new file mode 100644 index 0000000..8902eac --- /dev/null +++ b/app/src/husky/res/values-eu/husky_generated.xml @@ -0,0 +1,59 @@ + + + Husky software libre eta kode askekoa da. + \"GNU General Public License Version 3\" lizentziapean zabaldua. + Lizentzia hontaz gehiago irakurtzeko: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Huskyren profila + + + Aplikazioa berrabiarazi beharko duzu aldaketa ezartzeko + + + Husky-k ondorengo proiektuetako kode eta baliabideak ditu: + + + Husky %s + + + Husky-k sustatuta + + + + + + Proiektuaren gunea:\n + https://huskyapp.dev + + + + + + Akatsen berri-emateak eta hobekuntza-eskariak: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Saioa hasi + + + Pleroma kontua gehitu + + + Pleromaek gutxienez 5 minutuko programazio-tartea du. + + + + + Sartu hemen helbidea edo mastodon.eus, mastodon.jalgi.eus, shitposter.club bezalako edozein instantzia, +\n +\n Oraindik ez baduzu konturik, instantziaren izena sartu eta bertan kontua sortu dezakezu. +\n +\nInstantzia zure kontua dagoen gunea da, baino beste instantzietako erabiltzaileak zurean egongo balira bezala jarraitu ditzakezu. +\n +\nInformazio gehiago joinmastodon.org helbidean topatuko duzu. + + + diff --git a/app/src/husky/res/values-fa/husky_generated.xml b/app/src/husky/res/values-fa/husky_generated.xml new file mode 100644 index 0000000..815fc5f --- /dev/null +++ b/app/src/husky/res/values-fa/husky_generated.xml @@ -0,0 +1,56 @@ + + + تاسکی نرم‌افزاری آزاد است که تحت نگارش ۳ از پروانهٔ جامع همگانی گنو منتشر شده است. پروانه را می‌توانید از این‌جا ببینید: https://www.gnu.org/licenses/gpl-3.0.en.html + + + نمایهٔ تاسکی + + + برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید + + + تاسکی کد و دارایی‌هایی از پروژه‌های نرم‌افزار آزاد زیر دارد: + + + تاسکی %s + + + قدرت‌گرفته از تاسکی + + + + + + پایگاه وب پروژه : +\n https://huskyapp.dev + + + + + گزارش مشکلات و درخواست ویژگی‌ها: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + ورود با ماستودون + + + افزودن حساب ماستودون جدید + + + ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد. + + + + + نشانی یا دامنهٔ هر نمونه‌ای می‌تواند وارد شود، مثل shitposter.club, blob.cat, expired.mentality.rip, و بیش‌تر!. +\n +\n اگر هنوز حسابی ندارید، می‌توانید نام نمونه مورد نظر را وارد کرده و در آن حسابی بسازید. +\n +\n نمونه، جاییست که حسابتان رویش میزبانی می‌شود، ولی به راحتی می‌توانید با دیگر افراد روی نمونه‌های دیگر ارتباط داشته و دنبالشان کنید؛ انگار که روی یک پایگاه باشید. +\n +\nاطّلاعات بیش‌تر می‌تواند در joinmastodon.org پیدا شود. + + + diff --git a/app/src/husky/res/values-fr/husky_generated.xml b/app/src/husky/res/values-fr/husky_generated.xml new file mode 100644 index 0000000..be1e418 --- /dev/null +++ b/app/src/husky/res/values-fr/husky_generated.xml @@ -0,0 +1,62 @@ + + + Husky %s + + + Husky est une application libre et open source. + Elle est publiée sous licence publique générale GNU version 3. + Vous pouvez consulter la licence ici : https://www.gnu.org/licenses/gpl-3.0.fr.html + + + Profil de Husky + + + Vous devrez redémarrer Husky pour appliquer ces modifications + + + Husky contient du code et des ressources issus des projets open source suivants : + + + Propulsé par Husky + + + + + + Site du projet :\n + https://huskyapp.dev + + + + + + Rapports d’anomalies & demandes de fonctionnalités :\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Se connecter à Pleroma + + + Ajouter un nouveau compte Pleroma + + + L’intervalle minimum de planification sur Pleroma est de 5 minutes. + + + + + Indiquer ici l’adresse ou le domaine d’une instance, + comme shitposter.club, blob.cat, expired.mentality.rip, + et bien d’autres encore (en anglais) ! + \n\nSi vous ne disposez d’aucun compte, vous pouvez renseigner le nom de l’instance que vous souhaitez rejoindre + et y créer un compte.\n\nUne instance est l’endroit où votre compte est + hébergé, mais vous pouvez facilement suivre des personnes d’autres instances et communiquer avec elles + comme si vous étiez sur le même site. + \n\nPour plus d’informations, consultez joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-fr/strings.xml b/app/src/husky/res/values-fr/strings.xml new file mode 100644 index 0000000..fd039a1 --- /dev/null +++ b/app/src/husky/res/values-fr/strings.xml @@ -0,0 +1,21 @@ + + + %s a réagi avec %s à votre message + Notification pour les reactions par emojis + %s a réagi avec + Site internet de l\'application + mes messages peuvent recevoir des réactions + Répondre à + Réagir + Supprimer la réaction + Réaction de + Cacher totalement les utilisateurs et utilisatrices masqués + Modérateur•rice + Nom de l\'appliction + Réactions + La taille du fichier dépasse la limite de l\'instance + Activer %s + Désactiver %s + Administrateur•rice + Syntaxe de formatage par défaut (si supportée par l\'instance) + diff --git a/app/src/husky/res/values-ga/husky_generated.xml b/app/src/husky/res/values-ga/husky_generated.xml new file mode 100644 index 0000000..fb3cef1 --- /dev/null +++ b/app/src/husky/res/values-ga/husky_generated.xml @@ -0,0 +1,56 @@ + + + Próifíl Husky + + + Is bogearraí foinse oscailte agus saor in aisce é Husky. Tá sé ceadúnaithe faoi Leagan 3. Ceadúnas Poiblí Ginearálta GNU 3. Is féidir leat an ceadúnas a fheiceáil anseo: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Cumhachtaithe ag Husky + + + Husky %s + + + Beidh ort Husky a atosú chun na hathruithe seo a chur i bhfeidhm + + + Tá cód agus sócmhainní ó na tionscadail foinse oscailte seo a leanas i Husky: + + + + + + Suíomh Gréasáin an tionscadail: +\n https://huskyapp.dev + + + + + Tuarascálacha ar fhabhtanna & iarratais ar ghnéithe: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Logáil isteach le Pleroma + + + Cuir Cuntas Pleroma nua leis + + + Tá eatramh sceidealaithe íosta 5 nóiméad ag Pleroma. + + + + + Is féidir seoladh nó fearann aon cháis a iontráil anseo, mar shampla shitposter.club, blob.cat, expired.mentality.rip, agus níos mó! +\n +\nMura bhfuil cuntas agat fós, is féidir leat ainm an cháis ar mhaith leat a bheith páirteach ann agus cuntas a chruthú ann. +\n +\nIs áit amháin é sampla ina ndéantar do chuntas a óstáil, ach is féidir leat cumarsáid a dhéanamh go héasca le daoine eile agus iad a leanúint ar chásanna eile mar a bheadh tú ar an suíomh céanna. +\n +\nIs féidir tuilleadh faisnéise a fháil ag joinmastodon.org . + + + diff --git a/app/src/husky/res/values-gd/husky_generated.xml b/app/src/husky/res/values-gd/husky_generated.xml new file mode 100644 index 0000000..ea59b77 --- /dev/null +++ b/app/src/husky/res/values-gd/husky_generated.xml @@ -0,0 +1,11 @@ + + + + + + + Clàraich a-steach le Pleroma + + + + diff --git a/app/src/husky/res/values-hi/husky_generated.xml b/app/src/husky/res/values-hi/husky_generated.xml new file mode 100644 index 0000000..a3a54ec --- /dev/null +++ b/app/src/husky/res/values-hi/husky_generated.xml @@ -0,0 +1,46 @@ + + + टस्की में निम्नलिखित ओपन सोर्स परियोजनाओं से कोड और संपत्ति हैं: + + + इन परिवर्तनों को लागू करने के लिए आपको टस्की को पुनः आरंभ करना होगा + + + टस्की की प्रोफाइल + + + टस्की स्वतंत्र और ओपन-सोर्स सॉफ्टवेयर है। यह GNU जनरल पब्लिक लाइसेंस संस्करण 3 के तहत लाइसेंस प्राप्त है। आप लाइसेंस यहां देख सकते हैं: https://www.gnu.org/licenses/gpl-3.0.en.html + + + टस्की द्वारा संचालित + + + टस्की %s + + + + + + परियोजना की वेबसाइट: +\n https://huskyapp.dev + + + + + बग रिपोर्ट और सुविधा अनुरोध: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + हिंदी + + + मास्टोडन का न्यूनतम शेड्यूलिंग अंतराल 5 मिनट है। + + + नया मास्टोडन खाता जोड़ें + + + + diff --git a/app/src/husky/res/values-hi/strings.xml b/app/src/husky/res/values-hi/strings.xml new file mode 100644 index 0000000..19dcb9f --- /dev/null +++ b/app/src/husky/res/values-hi/strings.xml @@ -0,0 +1,7 @@ + + + जवाब दे + किसने प्रतिक्रिया व्यक्त की + प्रतिक्रिया + प्रतिक्रिया निकालें + \ No newline at end of file diff --git a/app/src/husky/res/values-hu/husky_generated.xml b/app/src/husky/res/values-hu/husky_generated.xml new file mode 100644 index 0000000..1b121e0 --- /dev/null +++ b/app/src/husky/res/values-hu/husky_generated.xml @@ -0,0 +1,57 @@ + + + Husky %s + + + Husky profilja + + + A beállítások érvényesítéséhez újra kell indítani a Husky-t + + + A Husky a következő nyílt forráskódú projektekből tartalmaz programkódot és más elemeket: + + + Husky ingyenes és nyílt forráskódú szoftver. A GNU General Public License Version 3 érvényes rá, amit itt tekinthetsz meg: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky által hajtva + + + + + + Projekt honlapja:\n + https://huskyapp.dev + + + + + + Hibajelentés & új funkciók igénylése: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Bejelentkezés Pleroma-nal + + + Új Pleroma fiók hozzáadása + + + A Pleromaban a legrövidebb ütemezhető időintervallum 5 perc. + + + + + Bármely példány címét vagy domain nevét beírhatod ide, mint a shitposter.club, az blob.cat, a expired.mentality.rip és mások! +\n +\nHa még nincs fiókod, beírhatod annak a példánynak a címét, amelyhez csatlakoznál, majd azon létrehozhatsz egy fiókot. +\n +\nA példány az a hely, ahol a fiókadataidat tárolják, de ettől még ugyanúgy kommunikálhatsz más példányokon lévő emberekkel, mintha ugyanazon az oldalon lennétek. +\n +\nTöbb információt találhatsz itt: joinmastodon.org. + + + diff --git a/app/src/husky/res/values-is/husky_generated.xml b/app/src/husky/res/values-is/husky_generated.xml new file mode 100644 index 0000000..2ca21d1 --- /dev/null +++ b/app/src/husky/res/values-is/husky_generated.xml @@ -0,0 +1,56 @@ + + + Husky %s + + + Keyrir á Husky + + + Husky er frjáls hugbúnaður með opinn grunnkóða. Hann er gefinn út með GNU General Public notkunarleyfi, útgáfu 3. Þú getur skoðað notkunarleyfið hér: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Notandasnið Husky + + + Það þarf að endurræsa Husky til að breytingarnar taki gildi + + + Husky inniheldur kóða og gögn frá eftirfarandi verkefnum með opinn grunnkóða: + + + + + + Vefsvæði verkefnisins: +\n https://huskyapp.dev + + + + + Villutilkynningar og beiðnir um nýja eiginleika: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Skrá inn með Pleroma + + + Bæta við nýjum Pleroma-aðgangi + + + Pleroma er með 5 mínútna lágmarksbil fyrir áætlaðar aðgerðir. + + + + + Hægt er að setja hér inn vistfang eða lén á hvaða tilviki sem er, svo sem shitposter.club, blob.cat, expired.mentality.rip og fleiri! +\n +\nEf þú ert ekki ennþá með notandaaðgang, geturðu sett inn nafnið á því tilviki sem þú vilt tilheyra og búið til aðgang þar. +\n +\nTilvik er ákveðinn einn vefþjónn þar sem notandaaðgangurinn þinn er hýstur, en eftir sem áður er auðvelt fyrir þig að eiga í samskiptum við fólk og fylgjast með einstaklingum á öðrum tilvikum, rétt eins og þið væruð á sama vefsvæðinu. +\n +\nNánari upplýsingar má finna á joinmastodon.org. + + + diff --git a/app/src/husky/res/values-it/husky_generated.xml b/app/src/husky/res/values-it/husky_generated.xml new file mode 100644 index 0000000..f594ce2 --- /dev/null +++ b/app/src/husky/res/values-it/husky_generated.xml @@ -0,0 +1,58 @@ + + + Husky %s + + + Husky è un programma libero ed open source. + È distribuito con licenza GNU General Public License Version 3. + Puoi leggere la licenza qui: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Profilo di Husky + + + Devi riavviare Husky per applicare queste modifiche + + + Husky contiene codice e risorse dai seguenti progetti open source: + + + Fatto con Husky + + + + + + Sito web del progetto:\n + https://huskyapp.dev + + + + + Segnala problemi & richiedi funzionalità:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + Accedi con Pleroma + + + Aggiungi un nuovo Account Pleroma + + + Pleroma ha un intervallo minimo di programmazione di 5 minuti. + + + + + L\'indirizzo o il dominio di qualsiasi istanza può essere inserito qui, come shitposter.club, blob.cat, expired.mentality.rip, e altro! +\n +\nSe non hai ancora un account, puoi inserire il nome di un\'istanza alla quale vuoi iscriverti e creare un account. +\n +\nUn\'istanza è il luogo dove l\'account è custodito, ma puoi facilmente comunicare e seguire gente su altre istanze come se fossero sullo stesso sito. +\n +\nPiù info possono essere trovate su joinmastodon.org. + + + diff --git a/app/src/husky/res/values-ja/husky_generated.xml b/app/src/husky/res/values-ja/husky_generated.xml new file mode 100644 index 0000000..f3abc9d --- /dev/null +++ b/app/src/husky/res/values-ja/husky_generated.xml @@ -0,0 +1,57 @@ + + + Husky %s + + + Huskyは無料のオープンソースソフトウェアです。GNU General Public License Version 3 の下で使用許諾されています。ライセンスはここからご覧いただけます: https://www.gnu.org/licenses/gpl-3.0.ja.html + + + Husky公式アカウント + + + これらの変更を適用するには、Huskyの再起動が必要になります + + + Huskyは、以下のオープンソース プロジェクトからのコードとアセットを含んでいます: + + + + + + プロジェクトのWebサイト(英語):\n + https://huskyapp.dev + + + + + + バグ報告 & 機能リクエスト(英語):\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Pleromaでログイン + + + 新しいPleromaアカウントを追加 + + + Pleromaにおける予約までの最小間隔は5分です。 + + + + + shitposter.club, blob.cat, expired.mentality.ripやその他 のような、あらゆるインスタンスのアドレスやドメインを入力できます。 +\n +\nまだアカウントをお持ちでない場合は、参加したいインスタンスの名前を入力することで そのインスタンスにアカウントを作成できます。 +\n +\nインスタンスはあなたのアカウントが提供される単独の場所ですが、他のインスタンスのユーザーとあたかも同じ場所にいるように簡単にコミュニケーションをとったりフォローしたりできます。 +\n +\nさらに詳しい情報はjoinmastodon.orgでご覧いただけます。 + + + diff --git a/app/src/husky/res/values-ja/strings.xml b/app/src/husky/res/values-ja/strings.xml new file mode 100644 index 0000000..1b13a23 --- /dev/null +++ b/app/src/husky/res/values-ja/strings.xml @@ -0,0 +1,24 @@ + + + 返信 + 絵文字反応 + 誰が反応したか + 可能にする %s + 無効にする %s + アプリの名前 + アプリのウェブサイト + 管理者 + モデレーター + 反応を削除 + ファイルが大きすぎます + 絵文字反応 + リピートを表示 + %s 誰が反応したか + ミュートされたユーザーを隠す + リピート + リピートを削除 + リピートを隠す + リピートを表示 + リピートを削除 + 削除しますか\? + \ No newline at end of file diff --git a/app/src/husky/res/values-kab/husky_generated.xml b/app/src/husky/res/values-kab/husky_generated.xml new file mode 100644 index 0000000..845690f --- /dev/null +++ b/app/src/husky/res/values-kab/husky_generated.xml @@ -0,0 +1,29 @@ + + + Husky %s + + + Amaɣnu n Husky + + + Yettwamdemmar s Husky + + + + + + Asmel Web n usenfaṛ: +\n https://huskyapp.dev + + + + + + Qqen ɣer Maṣṭudun + + + Rnu yiwen umiḍan amaynut n Maṣṭudun + + + + diff --git a/app/src/husky/res/values-ko/husky_generated.xml b/app/src/husky/res/values-ko/husky_generated.xml new file mode 100644 index 0000000..9ccef41 --- /dev/null +++ b/app/src/husky/res/values-ko/husky_generated.xml @@ -0,0 +1,50 @@ + + + Husky %s + + + Husky는 무료이며 오픈 소스입니다. 이 프로젝트는 GNU General Public License Version 3에 의해 배포됩니다. 이 페이지에서 라이선스 전문(영문)을 열람하실 수 있습니다: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky 공식 계정 + + + 변경 사항을 적용하려면 Husky를 재시작해야 합니다 + + + Husky에는 다음 오픈 소스 프로젝트의 요소/코드를 일부 활용하였습니다: + + + + + + 프로젝트 홈페이지: +\n https://huskyapp.dev + + + + + 버그 신고/건의사항: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + 마스토돈에 로그인 + + + 마스토돈 계정을 추가합니다 + + + + + 인스턴스의 도메인 주소나 IP주소를 입력하실 수 있습니다. shitposter.club, blob.cat, expired.mentality.rip 등이 있으며, 그 외에도 더 많은 인스턴스가 당신을 기다리고 있습니다! +\n +\n만약 계정이 없으시다면, 인스턴스 주소를 입력하신 후에 계정을 만드실 수 있습니다. +\n +\n여러분이 어느 인스턴스에 가입하시더라도, 다른 인스턴스에 있는 유저들과 문제 없이 소통하실 수 있습니다. +\n +\n자세한 사항은 joinmastodon.org을 참조하세요. + + + diff --git a/app/src/husky/res/values-ml/husky_generated.xml b/app/src/husky/res/values-ml/husky_generated.xml new file mode 100644 index 0000000..7d87c0f --- /dev/null +++ b/app/src/husky/res/values-ml/husky_generated.xml @@ -0,0 +1,11 @@ + + + + + + + മസ്റ്റഡോൺ വഴി പ്രവേശിക്കുക + + + + diff --git a/app/src/husky/res/values-nb-rNO/strings.xml b/app/src/husky/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..62f884a --- /dev/null +++ b/app/src/husky/res/values-nb-rNO/strings.xml @@ -0,0 +1,68 @@ + + + Notifikasjoner om nye emoji reaksjoner + Standard formatering syntaks(hvis støttet av instansen) + Utsett publisering + Del med den orginale målgruppen + Fjern reaksjon + Hvem reagerte + Deaktiver %s + Klistremerker + Applikasjons navn + Applikasjons nettside + Admin + An feil oppsto under henting av klistremerke + %s reagerte med %s på innlegget ditt + Aktiver eksperimentelle Pleroma-FE klistremerker(hvis tilgjengelig) + Innleggs synlighet + Åpne innlegg + Lag Innlegg + Slett og rediger dette innlegget\? + Skjul delinger + Del + Fjern deling + Vis delinger + POST + POST! + Vis delinger + Fjern deling + Del innlegg til… + Sender innlegg… + Sender innlegg + En kopi av innlegget er lagret i utkastene dine + Del innhold av innlegg + Del link til innlegg + %s delte + Planlagte innlegg + Delt av + Del innlegg URL til… + Svar på + Feil ved sending av innlegg + Aktiver %s + Moderator + %s reagerte med + Emoji Reaksjoner + Reager + Filen er større enn instansen tillater + postene mine får emoji-reaksjoner + Skjul dempede brukere + Aktiver større tilpassede emoji + Åpne innleggs deler + Delte + Slett dette innlegget\? + Feil ved sending av innlegg. + %s delte innlegget ditt + %s likte innlegget ditt + Delinger + Notifikasjoner når innleggene dine deles + Notifikasjon når innleggene dine favoriseres + mine innlegg blir delt + Vis delinger + Alltid utvid innlegg markert med sensitivt innhold + + %s Deling + % Delinger + + Vis bekreftelses melding før deling av innlegg + Innlegg + \ No newline at end of file diff --git a/app/src/husky/res/values-nl/husky_generated.xml b/app/src/husky/res/values-nl/husky_generated.xml new file mode 100644 index 0000000..daad548 --- /dev/null +++ b/app/src/husky/res/values-nl/husky_generated.xml @@ -0,0 +1,56 @@ + + + Husky %s + + + Husky is opensource- en vrije software. De licentie valt onder de GNU Algemene Publieke Licentie versie 3. Je kunt de licentie hier bekijken: https://www.gnu.org/licenses/gpl-3.0.nl.html + + + Husky\'s profiel + + + Je moet Husky herstarten om deze veranderingen te kunnen doorvoeren + + + Husky bevat broncode en onderdelen van de volgende opensourceprojecten: + + + Powered by Husky + + + + + + Projectwebsite:\n + https://huskyapp.dev + + + + + Foutmeldingen & nieuwe functies aanvragen:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + Aanmelden + + + Een nieuw Pleromaaccount toevoegen + + + Om in te plannen moet je in Pleroma een minimum interval van 5 minuten gebruiken. + + + + + Het adres of domein van elke Mastodonserver kan hier worden ingevoerd, zoals shitposter.club, mastodon.nl, octodon.social en nog veel meer! +\n +\nWanneer je nog geen account hebt, kun je de naam van de Mastodonserver waar jij je graag wil registeren invoeren, waarna je daar een account kunt aanmaken. +\n +\nEen Mastodonserver (Engels: instance) is een computerserver waar jouw account zich bevindt (vergelijk het met een e-mailserver). Je kan eenvoudig mensen van andere servers volgen en met ze communiceren, alsof jullie met elkaar op dezelfde website zitten. +\n +\n Meer informatie kun je vinden op joinmastodon.org. + + + diff --git a/app/src/husky/res/values-nn/strings.xml b/app/src/husky/res/values-nn/strings.xml new file mode 100644 index 0000000..89e8bd8 --- /dev/null +++ b/app/src/husky/res/values-nn/strings.xml @@ -0,0 +1,4 @@ + + + Svar på + \ No newline at end of file diff --git a/app/src/husky/res/values-no-rNB/husky_generated.xml b/app/src/husky/res/values-no-rNB/husky_generated.xml new file mode 100644 index 0000000..21182e4 --- /dev/null +++ b/app/src/husky/res/values-no-rNB/husky_generated.xml @@ -0,0 +1,56 @@ + + + Husky %s + + + Huskys Mastodon-profil + + + Husky er fri og åpen kildekode. Applikasjonen er lisensiert under GNU General Public License versjon 3. Du kan se lisensen her: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Du må starte Husky på nytt for at endringene skal bli aktive + + + Husky inneholder programkode og elementer fra følgende åpen kildekode-prosjekter: + + + Drevet av Husky + + + + + + Hjemmeside: +\n https://huskyapp.dev + + + + + Rapporter feil og ønsker om funksjonalitet her: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Logg inn med Pleroma + + + Legg til ny Pleroma-konto + + + Pleroma har et minimums planleggingsinterval på 5 minutter. + + + + + more! +\n +\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to join and create an account there. +\n +\nAn instance is a single place where your account is hosted, but you can easily communicate with and follow folks on other instances as though you were on the same site. +\n +\nMore info can be found at joinmastodon.org. + + + diff --git a/app/src/husky/res/values-oc/husky_generated.xml b/app/src/husky/res/values-oc/husky_generated.xml new file mode 100644 index 0000000..4adbc32 --- /dev/null +++ b/app/src/husky/res/values-oc/husky_generated.xml @@ -0,0 +1,60 @@ + + + Husky es programa gratuït, liure e de còdi dobèrt. + Es publicat jols tèrms de la licéncia publica generala GNU version 3. + Podeu trobar les llicència aquí: https://www.gnu.org/licenses/gpl-3.0.ca.html + + + Perfil de Husky + + + Vos caldrà reaviar Husky per aplicar aquestes cambiaments + + + Husky content de còdis e compausants dels projèctes liures seguents : + + + Husky %s + + + Propulsat per Husky + + + + + + Site web del projècte :\n + https://huskyapp.dev + + + + + + Rapòrts d\'errors e demandas de foncionalitats :\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Començar la session amb Pleroma + + + Apondre un nòu compte Pleroma + + + L’interval minimum de planificacion sus Pleroma e de 5 minutas. + + + + + Aquí podètz picar l\'adreça o domini de quina que siá instància, + coma mastodont.cat, shitposter.club, blob.cat o + fòrca mai ! + \n\nSi encara non avètz cap de compte, podètz picar lo nom de l’instància ont vos agradariá + anar e crear un compte enlà.\n\n + \n\nAvètz mas d’informacins a joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-pa/husky_generated.xml b/app/src/husky/res/values-pa/husky_generated.xml new file mode 100644 index 0000000..0b7834d --- /dev/null +++ b/app/src/husky/res/values-pa/husky_generated.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/husky/res/values-pl/husky_generated.xml b/app/src/husky/res/values-pl/husky_generated.xml new file mode 100644 index 0000000..7c2ded2 --- /dev/null +++ b/app/src/husky/res/values-pl/husky_generated.xml @@ -0,0 +1,58 @@ + + + Husky jest wolnym i otwartoźródłowym oprogramowaniem. Jest on dostępny na licencji GNU General Public License w wersji trzeciej. Możesz przeczytać przetłumaczoną treść licencji tutaj + + + Profil Husky’ego + + + Musisz uruchomić ponownie Huskyego, aby zastosować zmiany + + + Husky zawiera kod i zasoby następujących projektów open source: + + + Husky %s + + + Napędzane przez Husky + + + + + + Strona projektu:\n + https://huskyapp.dev + + + + + + Zgłoszenia błędów i propozycje funkcji:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Zaloguj się Kontem Pleroma + + + Dodaj nowe Konto Pleroma + + + Pleroma umożliwia wysłanie minimalnie 5 minut od zaplanowania. + + + + + Tutaj można wprowadzić domenę lub adres instancji, np. shitposter.club, blob.cat, expired.mentality.rip, i wiele więcej! +\n +\nJeżeli nie posiadasz jeszcze konta, wprowadź tu nazwę instancji, na której chcesz się zarejestrować. +\n +\nInstancja jest miejscem, na którym znajduje się twoje konto, lecz komunikując się z innymi serwerami, działa tak, jakby były jednym portalem. +\n +\nWięcej informacji można znaleźć na joinmastodon.org. + + + diff --git a/app/src/husky/res/values-pl/strings.xml b/app/src/husky/res/values-pl/strings.xml new file mode 100644 index 0000000..bc3d2b3 --- /dev/null +++ b/app/src/husky/res/values-pl/strings.xml @@ -0,0 +1,77 @@ + + + Nazwa aplikacji + Powtórz + %s powtórzył(-a) + Udostępnij odnośnik do postu + Udostępnij zawartość postu + Wystąpił błąd podczas wysyłania postu + + <b>%s</b> powtórzenie + <b>%s</b> powtórzenia + <b>%s</b> powtórzeń + <b>%s</b> powtórzeń + + Zaplanowane posty + Strona aplikacji + Reakcje emoji + Powiadomienia o nowych reakcjach emoji + Naklejki + %s zareagował %s na Twój post + Ukrywaj wyciszonych użytkowników + Zareaguj + Usuń reakcję + Kto zareagował + Zaplanuj post + Widoczność postu + Odpowiedź do + Domyślna składnia formatowania (jeśli jest obsługiwana przez instancję) + Włącz eksperymentalne naklejki Pleroma-FE (jeśli dostępne) + Otwórz post + Usunąć ten post\? + Usunąć i napisać ponownie ten post\? + %s powtórzył Twój post + %s dodał Twój post do ulubionych + Ukryj powtórzenia + Pokaż powtórzenia + Usuń powtórzenie + Pokaż powtórzenia + Powtórzenia + Otwórz konto osoby powtarzającej + Powtórz grupie docelowej autora oryginału + Cofnij powtórzenie + Stwórz post + Powtórzony + Powtórzone przez + Pokazuj powtórzenia + Wysyłanie postu… + Wysyłanie postów + Powiadomienia o podbiciu postów + Powiadomienia o dodaniu postów do ulubionych + Pytaj o potwierdzenie przed powtórzeniem + moje posty zostaną podbite + Udostępnij odnośnik do postu… + Udostępnij post do… + Moderator + Rozmiar pliku przekracza ograniczenia instancji + %s zareagowane przez + Wyłącz %s + Włącz %s + Kopia postu została zapisana jako szkic + Wysyłanie postu nie powiodło się. + Zawsze rozwijaj posty z ostrzeżeniami o zawartości + Zaplanowane posty + Oznacz jako przeczytane + Otwórz w zewnętrznej aplikacji + %s wysłał(-a) Tobie wiadomość + Prywatność + Może nieco zwiększyć zużycie energii + Zdjęcie + Wideo + Audio + Załącznik + Odnośnik + Odpowiedź dla %s + Ty + Inne + \ No newline at end of file diff --git a/app/src/husky/res/values-pt-rBR/husky_generated.xml b/app/src/husky/res/values-pt-rBR/husky_generated.xml new file mode 100644 index 0000000..5ab03ec --- /dev/null +++ b/app/src/husky/res/values-pt-rBR/husky_generated.xml @@ -0,0 +1,58 @@ + + + Husky %s + + + Husky é um software livre e de código aberto. + Ele é licenciado sob a versão 3 da Licença Pública Geral GNU. + Você pode ler a licença aqui: https://www.gnu.org/licenses/gpl-3.0.pt-br.html + + + Perfil do Husky + + + É necessário reiniciar o aplicativo para aplicar as alterações + + + O Husky contém código e recursos dos seguintes projetos de código aberto: + + + Desenvolvido por Husky + + + + + + Site do projeto:\n + https://huskyapp.dev + + + + + Reporte bugs e solicite funcionalidades: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Entrar com Pleroma + + + Adicionar nova conta Pleroma + + + Pleroma possui um intervalo mínimo de 5 minutos para agendar. + + + + + O domínio de qualquer instância pode ser inserido aqui, como shitposter.club, masto.donte.com.br, colorid.es ou qualquer outro! +\n +\n Se você não tem uma conta ainda, você pode inserir o nome da instância a qual você gostaria de participar e criar uma conta lá. +\n +\n Uma instância é um lugar onde sua conta é hospedada, mas você pode facilmente se comunicar e seguir pessoas de outras instâncias como se vocês estivessem no mesmo site. +\n +\n Mais informações podem ser encontradas em joinmastodon.org. + + + diff --git a/app/src/husky/res/values-pt-rBR/strings.xml b/app/src/husky/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..821bf7d --- /dev/null +++ b/app/src/husky/res/values-pt-rBR/strings.xml @@ -0,0 +1,68 @@ + + + Remover Ação + Nome Do App + Admin + Moderador + %s reagiu com %s no seu post + Reações de emoji + Notificações sobre novas reações de emoji + meus posts foram reagidos com emojis + Esconder usuários silenciados + Ligar Emojis Gigante Customizado + Ativar adesivos experimentais Pleroma-FE (se disponíveis) + Visibilidade do post + Agendar postagem + Remover Repetir + Repetir + Esconder Repetidos + Mostrar Repitidos + Post + Autor de repetição aberto + Mostrar Repetidos + Repetir para audiência original + Responder Para + Reagir + Quem Reagiu + Ativar %s + Desativar %s + Figurinhas + %s Reagido por + Site do app + O tamanho do arquivo excede os limites da instância + Ocorreu um erro ao buscar a figurinha + Sintaxe de formatação padrão (se suportada pela instância) + POST! + Remover Repetidos + Abrir Post + Compor Post + Repetido + Deletar esse post\? + Deletar e Refazer esse post\? + Erro ao mandar o post. + %s repetiu seu post + %s marcou seu post como favorito + Repetidos + Notificar quando seus post ficarem repetidos + Notificar quando seus post foram marcado como favorito + Mostrar confirmação de dialogo antes de repetir + meus post são repetidos + Mostrar repetidos + Sempre expanda postagens marcadas com avisos de conteúdo + + <b>%s</b> Repetido + <b>%s</b> Repetidos + + Compartilhar o url post para… + Compartilhar post para… + Enviando post… + Erro ao enviar post + Enviando posts + Uma cópia da postagem foi salva nos seus rascunhos + Compartilhar conteúdo da postagem + Compartilhar link para postar + %s repetido + Posts Agendados + Repetido por + Post + \ No newline at end of file diff --git a/app/src/husky/res/values-pt-rPT/strings.xml b/app/src/husky/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/app/src/husky/res/values-pt-rPT/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/husky/res/values-ru/husky_generated.xml b/app/src/husky/res/values-ru/husky_generated.xml new file mode 100644 index 0000000..34c4b3c --- /dev/null +++ b/app/src/husky/res/values-ru/husky_generated.xml @@ -0,0 +1,58 @@ + + + Husky %s + + + Husky – это бесплатное приложение с открытым исходным кодом. + Выпускается по лицензии GNU General Public License Version 3. + Вы можете прочитать текст лицензии по ссылке: https://www.gnu.org/licenses/gpl-3.0.ru.html + + + Профиль Husky + + + Вам нужно перезапустить Husky для применения изменений + + + Husky содержит код и элементы из следующих приложений с открытым исходным кодом: + + + Под управлением Husky + + + + + + + Веб-сайт проекта:\n + https://huskyapp.dev + + + + + + Отчеты об ошибках и пожелания:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + Войти + + + Добавить новый акканут Pleroma + + + Минимальный интервал планирования в Pleroma составляет 5 минут. + + + + + Здесь можно ввести адрес или домен любого узла, например, shitposter.club, blob.cat, expired.mentality.rip и других!\n\nЕсли у вас еще нет аккаунта, введите адрес узла, на котором хотите зарегистрироваться, и создайте аккаунт.\n\n + Узел - это то место, где размещен ваш аккаунт, но вы можете взаимодействовать с пользователями других узлов, как будто вы находитесь на одном сайте.\n + \n + Чтобы получить больше информации посетите joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-ru/strings.xml b/app/src/husky/res/values-ru/strings.xml new file mode 100644 index 0000000..b191e3e --- /dev/null +++ b/app/src/husky/res/values-ru/strings.xml @@ -0,0 +1,49 @@ + + + Чаты + Пометить как прочитанное + Ответ на + + Добавить реакцию + Удалить реакцию + Реакции + Включить %s + Отключить %s + %s реакции + Название приложения + Вебсайт приложения + Администратор + Модератор + Размер файла превышает лимиты инстанса + %s среагировал с %s на ваш пост + Эмодзи реакции + Уведомления о новых эмодзи реакциях + %s отправил вам сообщение + Сообщения + Уведомления о новых сообщениях + Синтаксис форматирования по умолчанию(если поддерживается) + на мои посты отреагировали + получено новое сообщение + Скрывать заглушенных пользователей + Произошла ошибка при загрузке стикера + ОТПРАВИТЬ! + Стикеры + Отложить пост + Включить эксперементальные стикеры Pleroma-FE (если доступны) + ОТПРАВИТЬ + Повторенно + Удалить запись\? + Скрыть повторения + Включить большие пользовательские эмодзи + Открыть запись + Повторить + Показать повторения + Повторить в оригинальной версии + Показать повторения + Отменить повторение + Записи по расписанию + Удалить повторение + Сделать запись + Ссылка + diff --git a/app/src/husky/res/values-sa/husky_generated.xml b/app/src/husky/res/values-sa/husky_generated.xml new file mode 100644 index 0000000..9de7276 --- /dev/null +++ b/app/src/husky/res/values-sa/husky_generated.xml @@ -0,0 +1,56 @@ + + + टस्कीवर्यस्य व्यक्तिगतविवरणम् + + + टस्कीत्यनावृतस्रोतो निःशुल्कतन्त्रांशः। GNU General Public License Version 3 इत्यनेनाऽनुज्ञापितः। अत्राऽनुज्ञापत्रं द्रष्टुं शक्यते:-https://www.gnu.org/licenses/gpl-3.0.en.html + + + टस्कीत्यनेनाऽऽश्रितः + + + टस्की %s + + + पुनश्च टस्कीप्रारम्भोऽपेक्षितो वर्तते परिवर्तनानुसरेण चलितुम् + + + टस्कीत्यस्मिन्निम्नलिखितेभ्योऽनावृतस्रोतःप्रकल्पेभ्यो विध्यादेशाः सन्ति: + + + + + + प्रकल्पस्य जालसूत्रम् : +\n https://huskyapp.dev + + + + + अशुद्धीनामावेदनं वैशिष्ट्यनिवेदनञ्च +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + मास्टुडोनमाध्यमेन सम्प्रविश्यताम् + + + नवमास्टोडोनलेखा युज्यताम् + + + मास्टोडोने पञ्चनिमेषपरिमितो न्यूनतमः कालबद्धसमयः । + + + + + कस्याऽपि विशिष्टस्थलस्य सङ्केतसूत्रमत्र टङ्कयितुं शक्यते shitposter.club, blob.cat, expired.mentality.rip, तथेैवअधिकम् +\n +\nयदि युष्माकं व्यक्तिगतलेखाऽत्र न वर्तते तर्हि तस्य विशिष्टस्थलस्य नाम टङ्कयित्वा तत्र निर्मातुं शक्नुथ । +\n +\nविशिष्टस्थलमित्युक्ते स्थलमेकं यत्र युष्माकं लेखाः आश्रिताः, किन्तु साफल्येनैवाऽन्यविशिष्टस्थलीयैः सह सम्पर्कयितुं शक्यते । +\n +\nअधिकमत्र प्राप्यते joinmastodon.org. + + + diff --git a/app/src/husky/res/values-sk/husky_generated.xml b/app/src/husky/res/values-sk/husky_generated.xml new file mode 100644 index 0000000..dae1859 --- /dev/null +++ b/app/src/husky/res/values-sk/husky_generated.xml @@ -0,0 +1,11 @@ + + + + + + + Prihlásiť sa účtom Pleroma + + + + diff --git a/app/src/husky/res/values-sl/husky_generated.xml b/app/src/husky/res/values-sl/husky_generated.xml new file mode 100644 index 0000000..4a9ad83 --- /dev/null +++ b/app/src/husky/res/values-sl/husky_generated.xml @@ -0,0 +1,53 @@ + + + Husky %s + + + Husky je prosta in odprtokodna programska oprema. Licencirana je pod licenco GNU General Public License različice 3. Licenco si lahko ogledate tukaj: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Profil Husky + + + Če želite uveljaviti te spremembe, morate znova zagnati Husky + + + Husky vsebuje kodo in sredstva iz naslednjih odprtokodnih projektov: + + + Poganja ga Husky + + + + + + Spletna stran projekta: +\nhttps://huskyapp.dev + + + + + Poročila o napakah in želje za nove funkcije: +\nhttps://git.mentality.rip/FWGS/Husky/issues + + + + + Prijavite se z Pleromaom + + + Dodaj nov Pleroma račun + + + + + Tu lahko vnesete naslov ali domeno katerega koli vozlišča, na primer shitposter.club, blob.cat, expired.mentality.rip in več! +\n +\nČe še nimate računa, lahko vnesete ime vozlišča, kateremu bi se radi pridružili, in tam ustvarite račun. +\n +\nVozlišče je ena lokacija, kjer je gostovanje vašega računa, vendar lahko preprosto komunicirate in sledite ljudem na drugih vozliščih, kot da bi bili na isti lokaciji. +\n +\nVeč informacij najdete na naslovu joinmastodon.org. + + + diff --git a/app/src/husky/res/values-sv/husky_generated.xml b/app/src/husky/res/values-sv/husky_generated.xml new file mode 100644 index 0000000..ecd46f0 --- /dev/null +++ b/app/src/husky/res/values-sv/husky_generated.xml @@ -0,0 +1,59 @@ + + + Husky %s + + + Husky är fri programvara med öppen källkod. Det är licensierat under GNU General Public License version 3. Du kan läsa mer om licensen här: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Huskys Profil + + + Du måste starta om Husky för att tillämpa ändringarna + + + Husky innehåller kod och tillgångar från följande öppen källkodsprojekt: + + + Drivs av Husky + + + + + + Tuskys webbsida:\n + https://huskyapp.dev + + + + + + Buggrapporter & funktionsförslag:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Logga in med Pleroma + + + Lägg till ett nytt Pleroma-konto + + + Pleroma har ett minimalt schemaläggningsintervall på 5 minuter. + + + + + Adressen eller domänen för varje instans kan anges + här, till exempel shitposter.club, blob.cat, expired.mentality.rip och + mer! + \n\nOm du inte har något konto kan du ange namnet på instansen du vill ansluta till och skapa ett konto där. + \n\nEn instans är en plats där ditt konto finns, men du kan enkelt kommunicera med och följa andra personer på andra instanser, + som om du var på samma sajt. + \n\nMer information finns på joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-sv/strings.xml b/app/src/husky/res/values-sv/strings.xml new file mode 100644 index 0000000..34387d1 --- /dev/null +++ b/app/src/husky/res/values-sv/strings.xml @@ -0,0 +1,69 @@ + + + Reagera + Ta bort reaktion + Vem reagerade + Aktivera %s + Avaktivera %s + %s reagerade av + Applikationsmanamn + Administratör + Klistermärken + Filstorleken är större än vad instanser tillåter + Ett fel inträffade vid hämtning av klistermärke + Emoji Reaktioner + Dölj tystade användare + Aktivera större anpassade emojis + Inläggssynlighet + Schemalägg inlägg + Repetera + Ta bort repetering + Dölj repeteringar + Visa repeteringar + SKICKA + SKICKA! + Svara till + Applikationswebbplats + Moderatorn + %s reagerade med %s på ditt inlägg + Aviseringar på nya emoji-reaktioner + Syntax på formatteringsstandard (om instansen stödjer det) + reaktioner på mina inlägg med emojis + Aktivera experimentell Pleroma-FE klistermärke (om möjligt) + Visa repeteringar + Schemalagda inlägg + Repetera till den ursprungliga målgruppen + Ta bort repetering + Öppna inlägg + Skriv inlägg + Ta bort detta inlägg\? + Öppna avsändaren av repeteringen + Repeterat + Radera och skriva en nytt inlägg\? + Fel vid sändning av inlägg. + %s upprepade ditt inlägg + %s favoriserade ditt inlägg + Upprepningar + Aviseringar när dina inlägg blir favoriserade + mina inlägg är repeterade + Visa upprepningar + Expandera alltid inlägg med innehållsvarningar + + %s Repeterade + %s Repeterades + + Dela inläggs-URL till… + Dela inlägg till… + Skickar inlägg… + Skickar inläggen + En kopia av inlägget har sparats i dina utkast + Dela innehåll av inlägg + Dela länk till inlägg + %s repeterade + Schemalagda inlägg + Upprepad av + Posta + Aviseringar när dina inlägg blir upprepade + Visa en bekräftelsedialog innan du repeterar + Fel vid sändning av inlägg + \ No newline at end of file diff --git a/app/src/husky/res/values-ta/husky_generated.xml b/app/src/husky/res/values-ta/husky_generated.xml new file mode 100644 index 0000000..227d1f1 --- /dev/null +++ b/app/src/husky/res/values-ta/husky_generated.xml @@ -0,0 +1,50 @@ + + + Husky(டஸ்கி) %s + + + Husky ஒரு கட்டற்ற மற்றும் திறந்த மூல மென்பொருள். இதன் உரிமம் GNU General Public License(பொது உரிமம்) பதிப்பு 3 -ன் கீழ் உள்ளது. நீங்கள் உரிமம் பற்றி காண: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky-ன் கணக்கு + + + இந்த மாறுதல்கள் செயற்படுத்த செயலியை மறுதொடக்கம் செய்ய வேண்டும் + + + Husky கொண்டுள்ள நிரல் மற்றும் துணுக்குகள் பின்வரும் திறந்த மூல திட்டங்கள்: + + + + + + திட்டத்தின் வலைத்தளம்:\n + https://huskyapp.dev + + + + + + பிழை அறிக்கைகள் & அம்ச கோரிக்கைகள்:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Pleroma மூலம் உள்நுழைய + + + புதிய Pleroma கணக்கைச் சேர்க்க + + + + + ஏதேனும் instance-ன் முகவரியையோ அல்லது களத்தின் முகவரியையோ இங்கு உள்ளிடவும், உதாரணமாக shitposter.club, blob.cat, expired.mentality.rip, மற்றும் + மேலும்! + \n\nபயனர் கணக்கு இல்லையெனில் புதிய கணக்கிற்கான instance(களம்)-னை பதிவிடவும். நீங்கள் குறிப்பிடப்படும் களத்தில் உங்கள் கணக்கு பதிவாகும்.\n\nமேலும் இங்கு குறிப்பிடப்பட்ட ஏதேனும் ஒரு களத்தில் மட்டுமே உங்களால் கணக்கு ஆரம்பித்துக்கொள்ள இயலும் இருப்பினும் நம்மால் மற்ற களங்களில் உள்ள நண்பர்களையும் தொடர்பு கொள்ள இயலும் . + \n\nமேலும் தகவல்கள் அறிய joinmastodon.org. + + + + diff --git a/app/src/husky/res/values-te/husky_generated.xml b/app/src/husky/res/values-te/husky_generated.xml new file mode 100644 index 0000000..0b7834d --- /dev/null +++ b/app/src/husky/res/values-te/husky_generated.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/husky/res/values-th/husky_generated.xml b/app/src/husky/res/values-th/husky_generated.xml new file mode 100644 index 0000000..201f22b --- /dev/null +++ b/app/src/husky/res/values-th/husky_generated.xml @@ -0,0 +1,56 @@ + + + Husky มีโค้ดและสินทรัพย์จากโครงการโอเพนซอร์สต่อไปนี้: + + + จำเป็นต้องเริ่ม Husky ใหม่ เพื่อใช้การเปลี่ยนแปลงเหล่านี้ + + + บัญชีทางการของ Husky + + + Husky คือซอฟต์แวร์เสรีและโอเพนซอร์ส ภายใต้สัญญาอนุญาต GNU General Public License Version 3 ดูสัญญาที่ : https://www.gnu.org/licenses/gpl-3.0.ja.html + + + ขับเคลื่อนด้วย Husky + + + Husky %s + + + + + + เว็บไซต์โปรเจกต์: +\nhttps://huskyapp.dev + + + + + รายงานช่องโหว่ และ ขอฟีเจอร์ (ภาษาอังกฤษ): +\nhttps://git.mentality.rip/FWGS/Husky/issues + + + + + เพิ่มบัญชี Pleroma ใหม่ + + + เข้าสู่ระบบด้วย Pleroma + + + Pleroma กำหนดเวลาขั้นต่ำ 5 นาที + + + + + ใส่ที่อยู่หรือโดเมนของ Instance ได้ที่นี่ เช่น shitposter.club blob.cat expired.mentality.rip และ อีกมากมาย! +\n +\nถ้ายังไม่มีบัญชี สามารถใส่ชื่อ Instance ที่ต้องการจะร่วมแล้วสร้างบัญชีที่นั่น +\n +\nInstance คือที่ที่หนึ่งไว้โฮสต์บัญชีคุณ แต่คุณยังสามารถสื่อสาร ติดตามบุคคลบน Instance อื่นได้เหมือนอยู่บนไซต์เดียวกัน +\n +\nพบข้อมูลเพิ่มเติมได้ที่ joinmastodon.org + + + diff --git a/app/src/husky/res/values-th/strings.xml b/app/src/husky/res/values-th/strings.xml new file mode 100644 index 0000000..30c3e8f --- /dev/null +++ b/app/src/husky/res/values-th/strings.xml @@ -0,0 +1,68 @@ + + + ค่าปริยายของไวยากรณ์การจัดรูปแบบ (ถ้ารองรับโดย Instance) + เปิดใช้งานเอโมจิที่กำหนดเองขนาดใหญ่ + โพสต์ + โพสต์! + เปิดโพสต์ + เขียนโพสต์ + ลบโพสต์นี้ \? + เกิดข้อผิดพลาดในการส่งโพสต์ + รีพีต + เปิดดูผู้รีพีต + แสดงรีพีต + แสดงรีพีต + ซ่อนรีพีต + ลบรีพีต + รีพีตแล้ว + ซ่อนผู้ใช้ที่ปิดเสียงไว้ + การมองเห็นโพสต์ + โพสต์แบบกำหนดเวลา + รีพีต + การแจ้งเตือนเมื่อโพสต์ของคุณถูกชื่นชอบ + %s ชื่นชอบโพสต์คุณ + โพสต์ฉันถูกรีพีตแล้ว + แสดงรีพีต + ขยายโพสต์ที่มีเครื่องหมายคำเตือนเนื้อหาเสมอ + + <b>%s</b> รีพีต + + กำลังส่งโพสต์… + เกิดข้อผิดพลาดในการส่งโพสต์ + แบ่งปันเนื้อหาของโพสต์ + %s ได้รีพีต + การโต้ตอบเอโมจิ + แบ่งปัน URL โพสต์ไป… + โพสต์ + ตอบกลับ + การแจ้งเตือนเกี่ยวกับการโต้ตอบเอโมจิใหม่ + ลบ แล้ว ร่างโพสต์นี้ใหม่ \? + แบ่งปันโพสต์ไป… + โพสต์ที่กำหนดเวลา + โพสต์ของฉันถูกโต้ตอบโดยเอโมจิ + รีพีตโดย + เปิดใช้งานสติกเกอร์ Pleroma-FE รุ่นทดลอง (ถ้ามี) + %s รีพีตโพสต์คุณ + โต้ตอบแบบเอโมจิ + %s ถูกโต้ตอบโดย + กำลังส่งโพสต์ + ขนาดไฟล์เกินขีดจำกัดกว่าที่ Instance กำหนดไว้ + ลบรีพีต + การแจ้งเตือนเมื่อโพสต์คุณถูกรีพีต + สำเนาโพสต์ได้บันทึกลงในฉบับร่างแล้ว + %s โต้ตอบด้วย %s แก่โพสต์คุณ + แสดงข้อความยืนยันก่อนที่จะรีพีต + แบ่งปันลิงก์ของโพสต์ + รีพีตโพสต์ต้นตอ + เปิดใช้งาน %s + ปิดใช้งาน %s + สติกเกอร์ + ชื่อแอป + เว็บไซต์ของแอป + ผู้ดูแล + ผู้ควบคุม + เกิดข้อผิดพลาดขณะดึงข้อมูลสติกเกอร์ + ลบโต้ตอบแบบเอโมจิ + ผู้โต้ตอบ + โพสต์แบบตั้งเวลา + \ No newline at end of file diff --git a/app/src/husky/res/values-tr/husky_generated.xml b/app/src/husky/res/values-tr/husky_generated.xml new file mode 100644 index 0000000..733f379 --- /dev/null +++ b/app/src/husky/res/values-tr/husky_generated.xml @@ -0,0 +1,56 @@ + + + Husky %s + + + Husky özgür ve açık kaynak bir yazılımdır. GNU Genel Kamu Lisansı sürüm 3 altında lisanslanmıştır. Lisansı buradan görüntüleyebilirsiniz: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky\'nin Profili + + + Bu değişiklikleri uygulamak için Husky\'yi yeniden başlatmanız gerekecek + + + Husky aşağıdakı açık kaynaklı projelerden kod ve materyal içeriyor: + + + Husky tarafından desteklenmektedir + + + + + + Projenin internet sitesi: +\n https://huskyapp.dev + + + + + & özellik istekleri hata raporları: +\n https://git.mentality.rip/FWGS/Husky/issues + + + + + Pleroma ile giriş yap + + + Yeni Pleroma hesabı ekle + + + Pleroma\'un minimum 5 dakikalık zamanlama aralığı vardır. + + + + + Burada her hangi bir Mastodon sunucusunun adresi (shitposter.club, blob.cat, expired.mentality.rip, ve daha fazla!) girilebiliri. +\n +\nEğer hesabınız henüz yok ise katılmak istediğiniz sunucunun adresini girerek hesap yaratabilirsin. +\n +\nHer bir sunucu hesaplar ağırlayan bir yer olur ancak diğer sunucularda bulunan insanlarla aynı sitede olmuşcasına iletişime geçip takip edebilirsiniz. +\n +\nDaha fazla bilgi için shitposter.club. + + + diff --git a/app/src/husky/res/values-tr/strings.xml b/app/src/husky/res/values-tr/strings.xml new file mode 100644 index 0000000..69b967b --- /dev/null +++ b/app/src/husky/res/values-tr/strings.xml @@ -0,0 +1,68 @@ + + + Yanıtla + Tepki + Tepki Kaldır + Kim tepki gösterdi + Etkinleştir + Etiket + tarafından tepki verildi + Uygulama Adı + Uygulama Sitesi + Yönetici + Moderatör + Dosya boyutu varsayılan sınırları aşıyor + %s tepki verdi %s paylaşımınıza + Emoji Tepkileri + Yeni emoji tepkileri hakkında bildirimler + gönderilerim emoji ile tepki verildi + Susturulmuş kullanıcıları gizle + Daha büyük özel emojileri etkinleştir + Deneysel Pleroma-FE etiketlerini etkinleştir (varsa) + Yayın görünürlüğü + Yayını zamanla + Tekrarla + Tekrarlamayı Kaldır + Tekrarlamaları Gizle + YAYIN + YAYIN! + Tekrar yazarı aç + Tekrarlamaları Göster + Orijinal kitleye tekrarlayın + Tekrarlamaları Kaldır + Yayın aç + Tekrarlanmış + Yayını Silecekmisiniz\? + Bu gönderi silinsin mi ve taslağı yeniden çizilsin mi\? + % s yayınınızı tekrarladı + Tekrarlamalar + Yayınlarınız favori olarak işaretlendiğinde bildirim gelsin + Tekrarlamadan önce onay iletişim kutusunu göster + gönderilerim tekrarlandı + Gönderileri göster + Yayın Bağlantısını şurada paylaş… + Yayınını şurada paylaş… + Yayın gönderiliyor… + Yayın gönderilirken bir hata oluştu + Gönderi gönderme + Yayının içeriğini paylaşma + Yayının bağlantısını paylaş + %s Tekrarlandı + Zamanlanmış yayınlar + Şu kişi tarafından tekrarlandı + Yayın + Devre Dışı Bırak + Çıkartma getirilirken bir hata oluştu + Varsayılan biçimlendirme sözdizimi (örnek tarafından destekleniyorsa) + Tekrarlamaları Göster + Yazı Oluştur + Gönderi gönderilirken hata oluştu. + % s yayınınızı favorilere ekledi + Gönderileriniz tekrarlandığında bildirimler + Her zaman içerik uyarılarıyla işaretlenmiş yayınları genişletin + Yayının bir kopyası taslaklarınıza kaydedildi + + </b><b>%s</b> Tekrar + <b>%s</b> Tekrarlar + + \ No newline at end of file diff --git a/app/src/husky/res/values-uk/husky_generated.xml b/app/src/husky/res/values-uk/husky_generated.xml new file mode 100644 index 0000000..5ed7679 --- /dev/null +++ b/app/src/husky/res/values-uk/husky_generated.xml @@ -0,0 +1,11 @@ + + + + + + + Увійти + + + + diff --git a/app/src/husky/res/values-uk/strings.xml b/app/src/husky/res/values-uk/strings.xml new file mode 100644 index 0000000..4b377d0 --- /dev/null +++ b/app/src/husky/res/values-uk/strings.xml @@ -0,0 +1,21 @@ + + + Відповісти + Відреагувати + Прибрати реакцію + Хто відреагував + Назва програми + Веб сторінка програми + Адміністратор + Модератор + %s відреагував %s до вашого посту + Емодзі Реакції + Сповіщення про нові емодзі реакції + Приховати приглушених користувачів + Ввімкнути %s + Вимкнути %s + %s відреагував + Розмір файлу перевищує обмеження інстанції + Синтакс форматування за замовчуванням (якщо підтримується інстанцією) + мої пости мають емодзі реакції + diff --git a/app/src/husky/res/values-vi/husky_generated.xml b/app/src/husky/res/values-vi/husky_generated.xml new file mode 100644 index 0000000..28c1720 --- /dev/null +++ b/app/src/husky/res/values-vi/husky_generated.xml @@ -0,0 +1,56 @@ + + + Husky là phần mềm mã nguồn mở, được phân phối với giấy phép GNU General Public License Version 3. Bạn có thể tham khảo thêm tại: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Powered by Husky + + + Husky %s + + + Trang cá nhân Husky + + + Husky có sử dụng mã nguồn từ những dự án mã nguồn mở sau: + + + Bạn cần khởi động lại Husky để áp dụng các thiết lập + + + + + + Trang chủ +\nhttps://huskyapp.dev + + + + + Báo lỗi và đề xuất tính năng +\nhttps://git.mentality.rip/FWGS/Husky/issues + + + + + Đăng nhập Pleroma + + + Pleroma giới hạn tối thiểu 5 phút. + + + Thêm tài khoản Pleroma + + + + + Bạn phải nhập một tên miền, ví dụ shitposter.club, blob.cat, expired.mentality.rip, và nhiều hơn nữa! +\n +\nNếu chưa có tài khoản, bạn phải tạo tài khoản trước ở đó. +\n +\nMáy chủ, nói cách khác là một cộng đồng nơi mà tài khoản của bạn lưu trữ trên đó, nhưng bạn vẫn có thể giao tiếp và theo dõi mọi người trên các máy chủ khác một cách dễ dàng. +\n +\nTham khảo thêm tại joinmastodon.org. + + + diff --git a/app/src/husky/res/values-zh-rCN/husky_generated.xml b/app/src/husky/res/values-zh-rCN/husky_generated.xml new file mode 100644 index 0000000..38523fe --- /dev/null +++ b/app/src/husky/res/values-zh-rCN/husky_generated.xml @@ -0,0 +1,60 @@ + + + Husky %s + + + Husky 是基于 GNU General Public License Version 3 许可证开源的自由软件。完整的许可证协议:https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky 官方帐号 + + + 你需要重启 Husky 才能生效 + + + Husky 使用了以下开源项目的源码: + + + 由Husky提供支持 + + + + + + + 项目地址:\n + https://huskyapp.dev + + + + + + + 问题反馈:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + 登录 Pleroma 帐号 + + + 添加新的 Pleroma 帐号 + + + Pleroma的最小预订时间为5分钟。 + + + + + 请输入你帐号所在的 Mastodon 站点的域名,比如 shitposter.club,blob.cat,expired.mentality.rip,等等 。 +\n +\n还没有 Mastodon 帐号?你也可以输入想注册的 Mastodon 站点的域名,然后在该服务器创建新的帐号并授权 Tusky 登入。 +\n +\n在 Mastodon 里,你的账号信息储存在某一特定实例当中,但 Mastodon 可使跨站互动和站内互动一样简单。 +\n +\n可以前往 https://joinmastodon.org 了解更多信息。 + + + diff --git a/app/src/husky/res/values-zh-rHK/husky_generated.xml b/app/src/husky/res/values-zh-rHK/husky_generated.xml new file mode 100644 index 0000000..fafdfb1 --- /dev/null +++ b/app/src/husky/res/values-zh-rHK/husky_generated.xml @@ -0,0 +1,45 @@ + + + Husky 是基於 GNU General Public License Version 3 許可證開源的自由軟體完整的許可證協議:https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky 官方帳號 + + + 你需要重啟 Husky 才能生效 + + + Husky 使用了以下開源專案的原始碼: + + + + + + + 專案網站:\n + https://huskyapp.dev + + + + + + + 問題回報:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + 登入 Pleroma 帳號 + + + 加入新的 Pleroma 帳號 + + + + + 請輸入你帳號所在的 Mastodon 站點的域名或地址 + + + diff --git a/app/src/husky/res/values-zh-rMO/husky_generated.xml b/app/src/husky/res/values-zh-rMO/husky_generated.xml new file mode 100644 index 0000000..fafdfb1 --- /dev/null +++ b/app/src/husky/res/values-zh-rMO/husky_generated.xml @@ -0,0 +1,45 @@ + + + Husky 是基於 GNU General Public License Version 3 許可證開源的自由軟體完整的許可證協議:https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky 官方帳號 + + + 你需要重啟 Husky 才能生效 + + + Husky 使用了以下開源專案的原始碼: + + + + + + + 專案網站:\n + https://huskyapp.dev + + + + + + + 問題回報:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + 登入 Pleroma 帳號 + + + 加入新的 Pleroma 帳號 + + + + + 請輸入你帳號所在的 Mastodon 站點的域名或地址 + + + diff --git a/app/src/husky/res/values-zh-rSG/husky_generated.xml b/app/src/husky/res/values-zh-rSG/husky_generated.xml new file mode 100644 index 0000000..94853a6 --- /dev/null +++ b/app/src/husky/res/values-zh-rSG/husky_generated.xml @@ -0,0 +1,51 @@ + + + Husky %s + + + Husky 是基于 GNU General Public License Version 3 许可证开源的自由软件。完整的许可证协议:https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky 官方帐号 + + + 你需要重启 Husky 才能生效 + + + Husky 使用了以下开源项目的源码: + + + + + + + 项目地址:\n + https://huskyapp.dev + + + + + + + 问题反馈:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + 登录 Pleroma 帐号 + + + 添加新的 Pleroma 帐号 + + + + + 请输入你帐号所在的 Mastodon 站点的域名,比如 pawoo.net,acg.mn,wxw.moe,等等 。 + \n\n还没有 Mastodon 帐号?你也可以输入想注册的 Mastodon 站点的域名,然后在该服务器创建新的帐号并授权 Tusky 登入。 + \n\n在 Mastodon 里,跨站互动和站内互动一样简单。可以前往 https://joinmastodon.org 了解更多信息。 + + + + diff --git a/app/src/husky/res/values-zh-rTW/husky_generated.xml b/app/src/husky/res/values-zh-rTW/husky_generated.xml new file mode 100644 index 0000000..3840283 --- /dev/null +++ b/app/src/husky/res/values-zh-rTW/husky_generated.xml @@ -0,0 +1,48 @@ + + + Husky 是基於 GNU General Public License Version 3 許可證開源的自由軟體完整的許可證協議:https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky 官方帳號 + + + 你需要重啟 Husky 才能生效 + + + Husky 使用了以下開源專案的原始碼: + + + Husky %s + + + + + + + 專案網站:\n + https://huskyapp.dev + + + + + + + 問題回報:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + 登入 Pleroma 帳號 + + + 加入新的 Pleroma 帳號 + + + + + 請輸入你帳號所在的 Mastodon 站點的域名或地址 + + + diff --git a/app/src/husky/res/values/donottranslate.xml b/app/src/husky/res/values/donottranslate.xml new file mode 100644 index 0000000..3e342ab --- /dev/null +++ b/app/src/husky/res/values/donottranslate.xml @@ -0,0 +1,9 @@ + + + + + https://huskyapp.dev + + + + diff --git a/app/src/husky/res/values/husky_donottranslate.xml b/app/src/husky/res/values/husky_donottranslate.xml new file mode 100644 index 0000000..374fcc8 --- /dev/null +++ b/app/src/husky/res/values/husky_donottranslate.xml @@ -0,0 +1,19 @@ + + Plaintext + Markdown + BBCode + HTML + + + @string/action_plaintext + @string/action_markdown + @string/action_bbcode + @string/action_html + + + + + %1$s; %2$s; %3$s + + + diff --git a/app/src/husky/res/values/husky_generated.xml b/app/src/husky/res/values/husky_generated.xml new file mode 100644 index 0000000..960fca3 --- /dev/null +++ b/app/src/husky/res/values/husky_generated.xml @@ -0,0 +1,64 @@ + + + Husky %s + + + Powered by Husky + + + Husky is free and open-source software. + It is licensed under the GNU General Public License Version 3. + You can view the license here: https://www.gnu.org/licenses/gpl-3.0.en.html + + + Husky\'s Profile + + + You\'ll need to restart Husky in order to apply these changes + + + Husky contains code and assets from the following open source projects: + + + + + + + Project website:\n + https://huskyapp.dev + + + + + + + Bug reports & feature requests:\n + https://git.mentality.rip/FWGS/Husky/issues + + + + + + Login with Pleroma + + + Add new Pleroma Account + + + Pleroma has a minimum scheduling interval of 5 minutes. + + + + + The address or domain of any instance can be entered + here, such as shitposter.club, blob.cat, expired.mentality.rip, and + more! + \n\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to + join and create an account there.\n\nAn instance is a single place where your account is + hosted, but you can easily communicate with and follow folks on other instances as though + you were on the same site. + \n\nMore info can be found at joinmastodon.org. + + + + diff --git a/app/src/husky/res/values/strings.xml b/app/src/husky/res/values/strings.xml new file mode 100644 index 0000000..0c32bff --- /dev/null +++ b/app/src/husky/res/values/strings.xml @@ -0,0 +1,166 @@ + + + Chats + You + + Recipient does not support Chats + + Mark as read + Reply to + React + Remove reaction + Who reacted + Enable %s + Disable %s + Stickers + Open in external app + Open chat + Expand menu + + %s reacted by + + Application name + Application website + + Admin + Moderator + + File size exceeds instance limits + An error occurred while fetching sticker + + %s reacted with %s to your post + Emoji Reactions + Notifications about new emoji reactions + %s sent you a message + Chat Messages + Notifications about new chat messages + %s just posted + Subscriptions + Notifications when somebody you\'re subscribed to published a new post + %s migrated to + Move + Notifications when somebody you\'re following migrated to another profile + + Other + Privacy + + Anonymize uploaded file names + Live notifications + May slightly increase power consumption + Default formatting syntax(if supported by instance) + my posts are reacted with emojis + received a chat message + somebody I\'m subscribed to published a new post + somebody I\'m following migrated to another profile + Hide muted users + Enable bigger custom emojis + Enable experimental Pleroma-FE stickers(if available) + Animate custom emojis + Render subscriptions as normal posts + + Image + Video + Audio + Attachment + + Link + + Live notifications + Running live notifications for: + + + Post visibility + Schedule post + Repeat + Remove repeat + Hide repeats + Show repeats + POST + POST! + Open repeat author + Show repeats + Scheduled posts + Repeat to original audience + Remove repeat + Open post + + Compose Post + + + Repeated + + Delete this post? + Delete and re-draft this post? + + Error sending post. + + %s repeated your post + %s favorited your post + Repeats + Notifications when your posts get repeated + Notifications when your posts get marked as favorite + + Show confirmation dialog before repeating + my posts are repeated + Show repeats + Always expand posts marked with content warnings + + + <b>%s</b> Repeat + <b>%s</b> Repeats + + Share post URL to… + Share post to… + Sending post… + Error sending post + Sending posts + A copy of the post has been saved to your drafts + + Share content of post + Share link to post + %s repeated + Reply to %s + + Scheduled posts + Repeated by + Post + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7a7236d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_bbcode.svg b/app/src/main/ic_bbcode.svg new file mode 100644 index 0000000..a24790d --- /dev/null +++ b/app/src/main/ic_bbcode.svg @@ -0,0 +1,74 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/app/src/main/ic_html.svg b/app/src/main/ic_html.svg new file mode 100644 index 0000000..3022409 --- /dev/null +++ b/app/src/main/ic_html.svg @@ -0,0 +1,74 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 0000000..fa60d18 Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/ic_launcher.svg b/app/src/main/ic_launcher.svg new file mode 100644 index 0000000..6bc832c --- /dev/null +++ b/app/src/main/ic_launcher.svg @@ -0,0 +1,146 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher_.svg b/app/src/main/ic_launcher_.svg new file mode 100644 index 0000000..6f5d87d --- /dev/null +++ b/app/src/main/ic_launcher_.svg @@ -0,0 +1,121 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher_foreground.svg b/app/src/main/ic_launcher_foreground.svg new file mode 100644 index 0000000..20995ba --- /dev/null +++ b/app/src/main/ic_launcher_foreground.svg @@ -0,0 +1,107 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_sticker.svg b/app/src/main/ic_sticker.svg new file mode 100644 index 0000000..1132521 --- /dev/null +++ b/app/src/main/ic_sticker.svg @@ -0,0 +1,65 @@ + + + + + + + + + + image/svg+xml + + + + + + + diff --git a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt new file mode 100644 index 0000000..f72fa45 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt @@ -0,0 +1,87 @@ +package com.keylesspalace.tusky + +import android.content.Intent +import android.os.Bundle +import androidx.annotation.StringRes +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.method.LinkMovementMethod +import android.text.style.URLSpan +import android.text.util.Linkify +import android.view.MenuItem +import android.widget.TextView +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.util.CustomURLSpan +import com.keylesspalace.tusky.util.hide +import kotlinx.android.synthetic.main.activity_about.* +import kotlinx.android.synthetic.main.toolbar_basic.* + +class AboutActivity : BottomSheetActivity(), Injectable { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_about) + + setSupportActionBar(toolbar) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + setTitle(R.string.about_title_activity) + + versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME) + + if(BuildConfig.CUSTOM_INSTANCE.isBlank()) { + aboutPoweredByTusky.hide() + } + + aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_tusky_license) + aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site) + aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site) + + tuskyProfileButton.setOnClickListener { + viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL) + } + + aboutLicensesButton.setOnClickListener { + startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java)) + } + + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + +} + +private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) { + + val text = SpannableString(context.getText(textId)) + + Linkify.addLinks(text, Linkify.WEB_URLS) + + val urlSpans = text.getSpans(0, text.length, URLSpan::class.java) + for (span in urlSpans) { + val start = text.getSpanStart(span) + val end = text.getSpanEnd(span) + val flags = text.getSpanFlags(span) + + val customSpan = object : CustomURLSpan(span.url) {} + + text.removeSpan(span) + text.setSpan(customSpan, start, end, flags) + } + + setText(text) + linksClickable = true + movementMethod = LinkMovementMethod.getInstance() + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt new file mode 100644 index 0000000..9deb2e1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -0,0 +1,974 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ArgbEvaluator +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.os.Bundle +import android.text.Editable +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.viewModels +import androidx.annotation.ColorInt +import androidx.annotation.Px +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat +import androidx.emoji.text.EmojiCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Observer +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.viewpager2.widget.MarginPageTransformer +import com.bumptech.glide.Glide +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.CollapsingToolbarLayout +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import com.keylesspalace.tusky.adapter.AccountFieldAdapter +import com.keylesspalace.tusky.components.chat.ChatActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.report.ReportActivity +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.pager.AccountPagerAdapter +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.showMuteAccountDialog +import com.keylesspalace.tusky.viewmodel.AccountViewModel +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.activity_account.* +import kotlinx.android.synthetic.main.view_account_moved.* +import java.text.NumberFormat +import javax.inject.Inject +import kotlin.math.abs + +class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener { + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: AccountViewModel by viewModels { viewModelFactory } + + private val accountFieldAdapter = AccountFieldAdapter(this) + + private var followState: FollowState = FollowState.NOT_FOLLOWING + private var blocking: Boolean = false + private var muting: Boolean = false + private var blockingDomain: Boolean = false + private var showingReblogs: Boolean = false + private var subscribing: Boolean = false + private var loadedAccount: Account? = null + + private var animateAvatar: Boolean = false + + // fields for scroll animation + private var hideFab: Boolean = false + private var oldOffset: Int = 0 + @ColorInt + private var toolbarColor: Int = 0 + @ColorInt + private var statusBarColorTransparent: Int = 0 + @ColorInt + private var statusBarColorOpaque: Int = 0 + + private var avatarSize: Float = 0f + @Px + private var titleVisibleHeight: Int = 0 + private lateinit var domain: String + + private enum class FollowState { + NOT_FOLLOWING, + FOLLOWING, + REQUESTED + } + + private lateinit var adapter: AccountPagerAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loadResources() + makeNotificationBarTransparent() + setContentView(R.layout.activity_account) + + // Obtain information to fill out the profile. + viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) + + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) + animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false) + hideFab = sharedPrefs.getBoolean("fabHide", false) + + setupToolbar() + setupTabs() + setupAccountViews() + setupRefreshLayout() + subscribeObservables() + + if (viewModel.isSelf) { + updateButtons() + saveNoteInfo.hide() + } else { + saveNoteInfo.visibility = View.INVISIBLE + } + } + + /** + * Load colors and dimensions from resources + */ + private fun loadResources() { + toolbarColor = ThemeUtils.getColor(this, R.attr.colorSurface) + statusBarColorTransparent = ContextCompat.getColor(this, R.color.transparent_statusbar_background) + statusBarColorOpaque = ThemeUtils.getColor(this, R.attr.colorPrimaryDark) + avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size) + titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) + } + + /** + * Setup account widgets visibility and actions + */ + private fun setupAccountViews() { + // Initialise the default UI states. + accountAdminTextView.hide() + accountModeratorTextView.hide() + accountFloatingActionButton.hide() + accountFollowButton.hide() + accountMuteButton.hide() + accountFollowsYouTextView.hide() + + // setup the RecyclerView for the account fields + accountFieldList.isNestedScrollingEnabled = false + accountFieldList.layoutManager = LinearLayoutManager(this) + accountFieldList.adapter = accountFieldAdapter + + + val accountListClickListener = { v: View -> + val type = when (v.id) { + R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS + R.id.accountFollowing -> AccountListActivity.Type.FOLLOWS + else -> throw AssertionError() + } + val accountListIntent = AccountListActivity.newIntent(this, type, viewModel.accountId) + startActivityWithSlideInAnimation(accountListIntent) + } + accountFollowers.setOnClickListener(accountListClickListener) + accountFollowing.setOnClickListener(accountListClickListener) + + accountStatuses.setOnClickListener { + // Make nice ripple effect on tab + accountTabLayout.getTabAt(0)!!.select() + val poorTabView = (accountTabLayout.getChildAt(0) as ViewGroup).getChildAt(0) + poorTabView.isPressed = true + accountTabLayout.postDelayed({ poorTabView.isPressed = false }, 300) + } + + // If wellbeing mode is enabled, follow stats and posts count should be hidden + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) + + if (wellbeingEnabled) { + accountStatuses.hide() + accountFollowers.hide() + accountFollowing.hide() + } + + } + + /** + * Init timeline tabs + */ + private fun setupTabs() { + // Setup the tabs and timeline pager. + adapter = AccountPagerAdapter(this, viewModel.accountId) + + accountFragmentViewPager.adapter = adapter + accountFragmentViewPager.offscreenPageLimit = 2 + + val pageTitles = arrayOf(getString(R.string.title_statuses), getString(R.string.title_statuses_with_replies), getString(R.string.title_statuses_pinned), getString(R.string.title_media)) + + TabLayoutMediator(accountTabLayout, accountFragmentViewPager) { tab, position -> + tab.text = pageTitles[position] + }.attach() + + val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) + accountFragmentViewPager.setPageTransformer(MarginPageTransformer(pageMargin)) + + accountTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) { + tab?.position?.let { position -> + (adapter.getFragment(position) as? ReselectableFragment)?.onReselect() + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) {} + + override fun onTabSelected(tab: TabLayout.Tab?) {} + + }) + } + + private fun setupToolbar() { + // set toolbar top margin according to system window insets + accountCoordinatorLayout.setOnApplyWindowInsetsListener { _, insets -> + val top = insets.systemWindowInsetTop + + val toolbarParams = accountToolbar.layoutParams as CollapsingToolbarLayout.LayoutParams + toolbarParams.topMargin = top + + insets.consumeSystemWindowInsets() + } + + // Setup the toolbar. + setSupportActionBar(accountToolbar) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setDisplayShowTitleEnabled(false) + } + + val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation) + + val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) + toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) + accountToolbar.background = toolbarBackground + + accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) + + val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply { + fillColor = ColorStateList.valueOf(toolbarColor) + elevation = appBarElevation + shapeAppearanceModel = ShapeAppearanceModel.builder() + .setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius)) + .build() + } + accountAvatarImageView.background = avatarBackground + + // Add a listener to change the toolbar icon color when it enters/exits its collapsed state. + accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { + + override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { + + if (verticalOffset == oldOffset) { + return + } + oldOffset = verticalOffset + + if (titleVisibleHeight + verticalOffset < 0) { + supportActionBar?.setDisplayShowTitleEnabled(true) + } else { + supportActionBar?.setDisplayShowTitleEnabled(false) + } + + if (hideFab && !viewModel.isSelf && !blocking) { + if (verticalOffset > oldOffset) { + accountFloatingActionButton.show() + } + if (verticalOffset < oldOffset) { + hideFabMenu() + accountFloatingActionButton.hide() + } + } + + val scaledAvatarSize = (avatarSize + verticalOffset) / avatarSize + + accountAvatarImageView.scaleX = scaledAvatarSize + accountAvatarImageView.scaleY = scaledAvatarSize + + accountAvatarImageView.visible(scaledAvatarSize > 0) + + val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(1f) + + window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int + + val evaluatedToolbarColor = argbEvaluator.evaluate(transparencyPercent, Color.TRANSPARENT, toolbarColor) as Int + + toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor) + + swipeToRefreshLayout.isEnabled = verticalOffset == 0 + } + }) + + } + + private fun makeNotificationBarTransparent() { + val decorView = window.decorView + decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + window.statusBarColor = statusBarColorTransparent + } + + /** + * Subscribe to data loaded at the view model + */ + private fun subscribeObservables() { + viewModel.accountData.observe(this) { + when (it) { + is Success -> onAccountChanged(it.data) + is Error -> { + Snackbar.make(accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { viewModel.refresh() } + .show() + } + } + } + viewModel.relationshipData.observe(this) { + val relation = it?.data + if (relation != null) { + onRelationshipChanged(relation) + } + + if (it is Error) { + Snackbar.make(accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { viewModel.refresh() } + .show() + } + + } + viewModel.accountFieldData.observe(this) { + accountFieldAdapter.fields = it + accountFieldAdapter.notifyDataSetChanged() + } + viewModel.noteSaved.observe(this) { + saveNoteInfo.visible(it, View.INVISIBLE) + } + } + + /** + * Setup swipe to refresh layout + */ + private fun setupRefreshLayout() { + swipeToRefreshLayout.setOnRefreshListener { + viewModel.refresh() + adapter.refreshContent() + } + viewModel.isRefreshing.observe(this) { isRefreshing -> + swipeToRefreshLayout.isRefreshing = isRefreshing == true + } + swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun onAccountChanged(account: Account?) { + loadedAccount = account ?: return + + val usernameFormatted = getString(R.string.status_username_format, account.username) + accountUsernameTextView.text = usernameFormatted + accountDisplayNameTextView.text = account.name.emojify(account.emojis, accountDisplayNameTextView) + + val emojifiedNote = account.note.emojify(account.emojis, accountNoteTextView) + LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this) + + // accountFieldAdapter.fields = account.fields ?: emptyList() + accountFieldAdapter.emojis = account.emojis ?: emptyList() + accountFieldAdapter.notifyDataSetChanged() + + accountLockedImageView.visible(account.locked) + accountBadgeTextView.visible(account.bot) + // API can return user is both admin and mod + // but admin rights already implies moderator, so just ignore it + val isAdmin = account.pleroma?.isAdmin ?: false + accountAdminTextView.visible(isAdmin) + accountModeratorTextView.visible(!isAdmin && account.pleroma?.isModerator ?: false) + + updateAccountAvatar() + updateToolbar() + updateMovedAccount() + updateRemoteAccount() + updateAccountStats() + invalidateOptionsMenu() + + accountMuteButton.setOnClickListener { + viewModel.unmuteAccount() + updateMuteButton() + } + } + + /** + * Load account's avatar and header image + */ + private fun updateAccountAvatar() { + loadedAccount?.let { account -> + + loadAvatar( + account.avatar, + accountAvatarImageView, + resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp), + animateAvatar + ) + + if(animateAvatar) { + Glide.with(this) + .load(account.header) + .centerCrop() + .into(accountHeaderImageView) + } else { + Glide.with(this) + .asBitmap() + .load(account.header) + .centerCrop() + .into(accountHeaderImageView) + } + + + accountAvatarImageView.setOnClickListener { avatarView -> + val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) + + avatarView.transitionName = account.avatar + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar) + + startActivity(intent, options.toBundle()) + } + } + } + + /** + * Update toolbar views for loaded account + */ + private fun updateToolbar() { + loadedAccount?.let { account -> + + val emojifiedName = account.name.emojify(account.emojis, accountToolbar, true) + + try { + supportActionBar?.title = EmojiCompat.get().process(emojifiedName) + } catch (e: IllegalStateException) { + supportActionBar?.title = emojifiedName + } + supportActionBar?.subtitle = String.format(getString(R.string.status_username_format), account.username) + } + } + + /** + * Update moved account info + */ + private fun updateMovedAccount() { + loadedAccount?.moved?.let { movedAccount -> + + accountMovedView?.show() + + // necessary because accountMovedView is now replaced in layout hierachy + findViewById(R.id.accountMovedViewLayout).setOnClickListener { + onViewAccount(movedAccount.id) + } + + accountMovedDisplayName.text = movedAccount.name + accountMovedUsername.text = getString(R.string.status_username_format, movedAccount.username) + + val avatarRadius = resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + + loadAvatar(movedAccount.avatar, accountMovedAvatar, avatarRadius, animateAvatar) + + accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name) + + // this is necessary because API 19 can't handle vector compound drawables + val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate() + val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) + + accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) + } + + } + + /** + * Check is account remote and update info if so + */ + private fun updateRemoteAccount() { + loadedAccount?.let { account -> + if (account.isRemote()) { + accountRemoveView.show() + accountRemoveView.setOnClickListener { + LinkHelper.openLink(account.url, this) + } + } + } + } + + private fun FloatingActionButton.menuAnimate(show: Boolean) { + val height = this.height.toFloat() + + if(show) { + visibility = View.VISIBLE + alpha = 0.0f + translationY = height + + animate().setDuration(200) + .translationY(0.0f) + .alpha(1.0f) + .setListener(object : AnimatorListenerAdapter() {}) // seems listener is saved, so reset it here + .start() + } else { + animate().setDuration(200) + .translationY(height) + .alpha(0.0f) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + visibility = View.GONE + super.onAnimationEnd(animation) + } + }) + .start() + } + } + + private fun hideFabMenu() { + openedFabMenu = false + + accountFloatingActionButton.animate().setDuration(200) + .rotation(0.0f).start() + accountFloatingActionButtonChat.menuAnimate(openedFabMenu) + accountFloatingActionButtonMention.menuAnimate(openedFabMenu) + + } + + var openedFabMenu = false + private fun animateFabMenu() { + if(openedFabMenu) { + hideFabMenu() + } else { + openedFabMenu = true + + accountFloatingActionButton.animate().setDuration(200) + .rotation(135.0f).start() + accountFloatingActionButtonChat.menuAnimate(openedFabMenu) + accountFloatingActionButtonMention.menuAnimate(openedFabMenu) + } + } + + /** + * Update account stat info + */ + private fun updateAccountStats() { + loadedAccount?.let { account -> + val numberFormat = NumberFormat.getNumberInstance() + accountFollowersTextView.text = numberFormat.format(account.followersCount) + accountFollowingTextView.text = numberFormat.format(account.followingCount) + accountStatusesTextView.text = numberFormat.format(account.statusesCount) + + accountFloatingActionButtonMention.setOnClickListener { mention() } + + if(account.pleroma?.acceptsChatMessages == true) { + accountFloatingActionButtonChat.setOnClickListener { + mastodonApi.createChat(account.id) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ + val intent = ChatActivity.getIntent(this@AccountActivity, it) + startActivityWithSlideInAnimation(intent) + }, { + Toast.makeText(this@AccountActivity, getString(R.string.error_generic), Toast.LENGTH_SHORT).show() + }) + } + } else { + accountFloatingActionButtonChat.backgroundTintList = ColorStateList.valueOf(Color.GRAY) + accountFloatingActionButtonChat.setOnClickListener { + Toast.makeText(this@AccountActivity, getString(R.string.error_chat_recipient_unavailable), Toast.LENGTH_SHORT).show() + } + } + + accountFloatingActionButton.setOnClickListener { animateFabMenu() } + + accountFollowButton.setOnClickListener { + if (viewModel.isSelf) { + val intent = Intent(this@AccountActivity, EditProfileActivity::class.java) + startActivity(intent) + return@setOnClickListener + } + + if (blocking) { + viewModel.changeBlockState() + return@setOnClickListener + } + + when (followState) { + FollowState.NOT_FOLLOWING -> { + viewModel.changeFollowState() + } + FollowState.REQUESTED -> { + showFollowRequestPendingDialog() + } + FollowState.FOLLOWING -> { + showUnfollowWarningDialog() + } + } + updateFollowButton() + } + } + } + + private fun onRelationshipChanged(relation: Relationship) { + followState = when { + relation.following -> FollowState.FOLLOWING + relation.requested -> FollowState.REQUESTED + else -> FollowState.NOT_FOLLOWING + } + blocking = relation.blocking + muting = relation.muting + blockingDomain = relation.blockingDomain + showingReblogs = relation.showingReblogs + + // If wellbeing mode is enabled, "follows you" text should not be visible + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) + + accountFollowsYouTextView.visible(relation.followedBy && !wellbeingEnabled) + + // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field + // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call + if(!viewModel.isSelf && followState == FollowState.FOLLOWING + && (relation.subscribing != null || relation.notifying != null)) { + accountSubscribeButton.show() + accountSubscribeButton.setOnClickListener { + viewModel.changeSubscribingState() + } + if(relation.notifying != null) + subscribing = relation.notifying + else if(relation.subscribing != null) + subscribing = relation.subscribing + } + + accountNoteTextInputLayout.visible(relation.note != null) + accountNoteTextInputLayout.editText?.setText(relation.note) + + // add the listener late to avoid it firing on the first change + accountNoteTextInputLayout.editText?.removeTextChangedListener(noteWatcher) + accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher) + + updateButtons() + } + + private val noteWatcher = object: DefaultTextWatcher() { + override fun afterTextChanged(s: Editable) { + viewModel.noteChanged(s.toString()) + } + } + + private fun updateFollowButton() { + if (viewModel.isSelf) { + accountFollowButton.setText(R.string.action_edit_own_profile) + return + } + if (blocking) { + accountFollowButton.setText(R.string.action_unblock) + return + } + when (followState) { + FollowState.NOT_FOLLOWING -> { + accountFollowButton.setText(R.string.action_follow) + } + FollowState.REQUESTED -> { + accountFollowButton.setText(R.string.state_follow_requested) + } + FollowState.FOLLOWING -> { + accountFollowButton.setText(R.string.action_unfollow) + } + } + updateSubscribeButton() + } + + private fun updateMuteButton() { + if (muting) { + accountMuteButton.setIconResource(R.drawable.ic_unmute_24dp) + } else { + accountMuteButton.hide() + } + } + + private fun updateSubscribeButton() { + if(followState != FollowState.FOLLOWING) { + accountSubscribeButton.hide() + } + + if(subscribing) { + accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp) + } else { + accountSubscribeButton.setIconResource(R.drawable.ic_notifications_24dp) + } + } + + private fun updateButtons() { + invalidateOptionsMenu() + + if (loadedAccount?.moved == null) { + + accountFollowButton.show() + updateFollowButton() + + if (blocking || viewModel.isSelf) { + hideFabMenu() + accountFloatingActionButton.hide() + accountMuteButton.hide() + accountSubscribeButton.hide() + } else { + accountFloatingActionButton.show() + if (muting) + accountMuteButton.show() + else + accountMuteButton.hide() + updateMuteButton() + } + + } else { + hideFabMenu() + accountFloatingActionButton.hide() + accountFollowButton.hide() + accountMuteButton.hide() + accountSubscribeButton.hide() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.account_toolbar, menu) + + if (!viewModel.isSelf) { + val follow = menu.findItem(R.id.action_follow) + follow.title = if (followState == FollowState.NOT_FOLLOWING) { + getString(R.string.action_follow) + } else { + getString(R.string.action_unfollow) + } + + follow.isVisible = followState != FollowState.REQUESTED + + val block = menu.findItem(R.id.action_block) + block.title = if (blocking) { + getString(R.string.action_unblock) + } else { + getString(R.string.action_block) + } + + val mute = menu.findItem(R.id.action_mute) + mute.title = if (muting) { + getString(R.string.action_unmute) + } else { + getString(R.string.action_mute) + } + + if (loadedAccount != null) { + val muteDomain = menu.findItem(R.id.action_mute_domain) + domain = LinkHelper.getDomain(loadedAccount?.url) + if (domain.isEmpty()) { + // If we can't get the domain, there's no way we can mute it anyway... + menu.removeItem(R.id.action_mute_domain) + } else { + if (blockingDomain) { + muteDomain.title = getString(R.string.action_unmute_domain, domain) + } else { + muteDomain.title = getString(R.string.action_mute_domain, domain) + } + } + } + + if (followState == FollowState.FOLLOWING) { + val showReblogs = menu.findItem(R.id.action_show_reblogs) + showReblogs.title = if (showingReblogs) { + getString(R.string.action_hide_reblogs) + } else { + getString(R.string.action_show_reblogs) + } + + } else { + menu.removeItem(R.id.action_show_reblogs) + } + + } else { + // It shouldn't be possible to block, follow, mute or report yourself. + menu.removeItem(R.id.action_follow) + menu.removeItem(R.id.action_block) + menu.removeItem(R.id.action_mute) + menu.removeItem(R.id.action_mute_domain) + menu.removeItem(R.id.action_show_reblogs) + menu.removeItem(R.id.action_report) + } + + return super.onCreateOptionsMenu(menu) + } + + private fun showFollowRequestPendingDialog() { + AlertDialog.Builder(this) + .setMessage(R.string.dialog_message_cancel_follow_request) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showUnfollowWarningDialog() { + AlertDialog.Builder(this) + .setMessage(R.string.dialog_unfollow_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun toggleBlockDomain(instance: String) { + if(blockingDomain) { + viewModel.unblockDomain(instance) + } else { + AlertDialog.Builder(this) + .setMessage(getString(R.string.mute_domain_warning, instance)) + .setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + private fun toggleBlock() { + if (viewModel.relationshipData.value?.data?.blocking != true) { + AlertDialog.Builder(this) + .setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username)) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } else { + viewModel.changeBlockState() + } + } + + private fun toggleMute() { + if (viewModel.relationshipData.value?.data?.muting != true) { + loadedAccount?.let { + showMuteAccountDialog( + this, + it.username + ) { notifications, duration -> + viewModel.muteAccount(notifications, duration) + } + } + } else { + viewModel.unmuteAccount() + } + } + + private fun mention() { + loadedAccount?.let { + val intent = ComposeActivity.startIntent(this, + ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))) + startActivity(intent) + } + } + + override fun onViewTag(tag: String) { + val intent = Intent(this, ViewTagActivity::class.java) + intent.putExtra("hashtag", tag) + startActivityWithSlideInAnimation(intent) + } + + override fun onViewAccount(id: String) { + val intent = Intent(this, AccountActivity::class.java) + intent.putExtra("id", id) + startActivityWithSlideInAnimation(intent) + } + + override fun onViewUrl(url: String) { + viewUrl(url) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + R.id.action_mention -> { + mention() + return true + } + R.id.action_open_in_web -> { + // If the account isn't loaded yet, eat the input. + if (loadedAccount != null) { + LinkHelper.openLink(loadedAccount?.url, this) + } + return true + } + R.id.action_follow -> { + viewModel.changeFollowState() + return true + } + R.id.action_block -> { + toggleBlock() + return true + } + R.id.action_mute -> { + toggleMute() + return true + } + R.id.action_mute_domain -> { + toggleBlockDomain(domain) + return true + } + R.id.action_show_reblogs -> { + viewModel.changeShowReblogsState() + return true + } + R.id.action_report -> { + if (loadedAccount != null) { + startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username)) + } + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun getActionButton(): FloatingActionButton? { + return if (!viewModel.isSelf && !blocking) { + accountFloatingActionButton + } else null + } + + override fun onActionButtonHidden() { + hideFabMenu() + } + + override fun androidInjector() = dispatchingAndroidInjector + + companion object { + + private const val KEY_ACCOUNT_ID = "id" + private val argbEvaluator = ArgbEvaluator() + + @JvmStatic + fun getIntent(context: Context, accountId: String): Intent { + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(KEY_ACCOUNT_ID, accountId) + return intent + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt new file mode 100644 index 0000000..33f9209 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt @@ -0,0 +1,105 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import com.keylesspalace.tusky.fragment.AccountListFragment +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class AccountListActivity : BaseActivity(), HasAndroidInjector { + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + + enum class Type { + FOLLOWS, + FOLLOWERS, + BLOCKS, + MUTES, + FOLLOW_REQUESTS, + REBLOGGED, + FAVOURITED, + REACTED + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_account_list) + + val type = intent.getSerializableExtra(EXTRA_TYPE) as Type + val id: String? = intent.getStringExtra(EXTRA_ID) + val emoji: String? = intent.getStringExtra(EXTRA_EMOJI) + + setSupportActionBar(toolbar) + supportActionBar?.apply { + when (type) { + Type.BLOCKS -> setTitle(R.string.title_blocks) + Type.MUTES -> setTitle(R.string.title_mutes) + Type.FOLLOW_REQUESTS -> setTitle(R.string.title_follow_requests) + Type.FOLLOWERS -> setTitle(R.string.title_followers) + Type.FOLLOWS -> setTitle(R.string.title_follows) + Type.REBLOGGED -> setTitle(R.string.title_reblogged_by) + Type.FAVOURITED -> setTitle(R.string.title_favourited_by) + Type.REACTED -> setTitle(String.format(getString(R.string.title_emoji_reacted_by), emoji)) + } + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + supportFragmentManager + .beginTransaction() + .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, emoji)) + .commit() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun androidInjector() = dispatchingAndroidInjector + + companion object { + private const val EXTRA_TYPE = "type" + private const val EXTRA_ID = "id" + private const val EXTRA_EMOJI = "emoji" + + @JvmStatic + fun newIntent(context: Context, type: Type, id: String?, emoji: String?): Intent { + return Intent(context, AccountListActivity::class.java).apply { + putExtra(EXTRA_TYPE, type) + putExtra(EXTRA_ID, id) + putExtra(EXTRA_EMOJI, emoji) + } + } + + @JvmStatic + fun newIntent(context: Context, type: Type, id: String? = null): Intent { + return newIntent(context, type, id, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt new file mode 100644 index 0000000..71aeb1b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -0,0 +1,285 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.DialogFragment +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel +import com.keylesspalace.tusky.viewmodel.State +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.fragment_accounts_in_list.* +import kotlinx.android.synthetic.main.item_follow_request.* +import java.io.IOException +import javax.inject.Inject + +private typealias AccountInfo = Pair + +class AccountsInListFragment : DialogFragment(), Injectable { + + companion object { + private const val LIST_ID_ARG = "listId" + private const val LIST_NAME_ARG = "listName" + + @JvmStatic + fun newInstance(listId: String, listName: String): AccountsInListFragment { + val args = Bundle().apply { + putString(LIST_ID_ARG, listId) + putString(LIST_NAME_ARG, listName) + } + return AccountsInListFragment().apply { arguments = args } + } + } + + @Inject + lateinit var viewModelFactory: ViewModelFactory + lateinit var viewModel: AccountsInListViewModel + + private lateinit var listId: String + private lateinit var listName: String + private val adapter = Adapter() + private val searchAdapter = SearchAdapter() + + private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } + private val animateAvatar by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("animateGifAvatars", false) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + viewModel = viewModelFactory.create(AccountsInListViewModel::class.java) + val args = arguments!! + listId = args.getString(LIST_ID_ARG)!! + listName = args.getString(LIST_NAME_ARG)!! + + viewModel.load(listId) + } + + override fun onStart() { + super.onStart() + dialog?.apply { + // Stretch dialog to the window + window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_accounts_in_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + accountsRecycler.layoutManager = LinearLayoutManager(view.context) + accountsRecycler.adapter = adapter + + accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context) + accountsSearchRecycler.adapter = searchAdapter + + viewModel.state + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe { state -> + adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) + + when (state.accounts) { + is Either.Right -> messageView.hide() + is Either.Left -> handleError(state.accounts.value) + } + + setupSearchView(state) + } + + searchView.isSubmitButtonEnabled = true + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + viewModel.search(query ?: "") + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + // Close event is not sent so we use this instead + if (newText.isNullOrBlank()) { + viewModel.search("") + } + return true + } + }) + } + + private fun setupSearchView(state: State) { + if (state.searchResult == null) { + searchAdapter.submitList(listOf()) + accountsSearchRecycler.hide() + accountsRecycler.show() + } else { + val listAccounts = state.accounts.asRightOrNull() ?: listOf() + val newList = state.searchResult.map { acc -> + acc to listAccounts.contains(acc) + } + searchAdapter.submitList(newList) + accountsSearchRecycler.show() + accountsRecycler.hide() + } + } + + private fun handleError(error: Throwable) { + messageView.show() + val retryAction = { _: View -> + messageView.hide() + viewModel.load(listId) + } + if (error is IOException) { + messageView.setup(R.drawable.elephant_offline, + R.string.error_network, retryAction) + } else { + messageView.setup(R.drawable.elephant_error, + R.string.error_generic, retryAction) + } + } + + private fun onRemoveFromList(accountId: String) { + viewModel.deleteAccountFromList(listId, accountId) + } + + private fun onAddToList(account: Account) { + viewModel.addAccountToList(listId, account) + } + + private object AccountDiffer : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean { + return oldItem.deepEquals(newItem) + } + } + + inner class Adapter : ListAdapter(AccountDiffer) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_follow_request, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), + View.OnClickListener, LayoutContainer { + + override val containerView = itemView + + init { + acceptButton.hide() + rejectButton.setOnClickListener(this) + rejectButton.contentDescription = + itemView.context.getString(R.string.action_remove_from_list) + } + + fun bind(account: Account) { + displayNameTextView.text = account.name.emojify(account.emojis, displayNameTextView) + usernameTextView.text = account.username + loadAvatar(account.avatar, avatar, radius, animateAvatar) + } + + override fun onClick(v: View?) { + onRemoveFromList(getItem(adapterPosition).id) + } + } + } + + private object SearchDiffer : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { + return oldItem.second == newItem.second + && oldItem.first.deepEquals(newItem.first) + } + + } + + inner class SearchAdapter : ListAdapter(SearchDiffer) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_follow_request, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val (account, inAList) = getItem(position) + holder.bind(account, inAList) + + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), + View.OnClickListener, LayoutContainer { + + override val containerView = itemView + + fun bind(account: Account, inAList: Boolean) { + displayNameTextView.text = account.name.emojify(account.emojis, displayNameTextView) + usernameTextView.text = account.username + loadAvatar(account.avatar, avatar, radius, animateAvatar) + + rejectButton.apply { + if (inAList) { + setImageResource(R.drawable.ic_reject_24dp) + contentDescription = getString(R.string.action_remove_from_list) + } else { + setImageResource(R.drawable.ic_plus_24dp) + contentDescription = getString(R.string.action_add_to_list) + } + } + } + + init { + acceptButton.hide() + rejectButton.setOnClickListener(this) + } + + override fun onClick(v: View?) { + val (account, inAList) = getItem(adapterPosition) + if (inAList) { + onRemoveFromList(account.id) + } else { + onAddToList(account) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java new file mode 100644 index 0000000..3638726 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -0,0 +1,222 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky; + +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +import com.google.android.material.snackbar.Snackbar; +import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.interfaces.AccountSelectionListener; +import com.keylesspalace.tusky.interfaces.PermissionRequester; +import com.keylesspalace.tusky.util.ThemeUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import javax.inject.Inject; + +public abstract class BaseActivity extends AppCompatActivity implements Injectable { + + @Inject + public AccountManager accountManager; + + private static final int REQUESTER_NONE = Integer.MAX_VALUE; + private HashMap requesters; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + + /* There isn't presently a way to globally change the theme of a whole application at + * runtime, just individual activities. So, each activity has to set its theme before any + * views are created. */ + String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT); + Log.d("activeTheme", theme); + if (theme.equals("black")) { + setTheme(R.style.TuskyBlackTheme); + } + + /* set the taskdescription programmatically, the theme would turn it blue */ + String appName = getString(R.string.app_name); + Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); + int recentsBackgroundColor = ThemeUtils.getColor(this, R.attr.colorSurface); + + setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); + + int style = textStyle(preferences.getString("statusTextSize", "medium")); + getTheme().applyStyle(style, false); + + if(requiresLogin()) { + redirectIfNotLoggedIn(); + } + + requesters = new HashMap<>(); + } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(TuskyApplication.getLocaleManager().setLocale(base)); + } + + protected boolean requiresLogin() { + return true; + } + + private static int textStyle(String name) { + int style; + switch (name) { + case "smallest": + style = R.style.TextSizeSmallest; + break; + case "small": + style = R.style.TextSizeSmall; + break; + case "medium": + default: + style = R.style.TextSizeMedium; + break; + case "large": + style = R.style.TextSizeLarge; + break; + case "largest": + style = R.style.TextSizeLargest; + break; + } + return style; + } + + public void startActivityWithSlideInAnimation(Intent intent) { + super.startActivity(intent); + overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left); + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right); + } + + public void finishWithoutSlideOutAnimation() { + super.finish(); + } + + protected void redirectIfNotLoggedIn() { + AccountEntity account = accountManager.getActiveAccount(); + if (account == null) { + Intent intent = new Intent(this, LoginActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivityWithSlideInAnimation(intent); + finish(); + } + } + + protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) { + if (anyView != null) { + Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); + bar.setAction(actionId, listener); + bar.show(); + } + } + + public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) { + List accounts = accountManager.getAllAccountsOrderedByActive(); + AccountEntity activeAccount = accountManager.getActiveAccount(); + + switch(accounts.size()) { + case 1: + listener.onAccountSelected(activeAccount); + return; + case 2: + if (!showActiveAccount) { + for (AccountEntity account : accounts) { + if (activeAccount != account) { + listener.onAccountSelected(account); + return; + } + } + } + break; + } + + if (!showActiveAccount && activeAccount != null) { + accounts.remove(activeAccount); + } + AccountSelectionAdapter adapter = new AccountSelectionAdapter(this); + adapter.addAll(accounts); + + new AlertDialog.Builder(this) + .setTitle(dialogTitle) + .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) + .show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requesters.containsKey(requestCode)) { + PermissionRequester requester = requesters.remove(requestCode); + requester.onRequestPermissionsResult(permissions, grantResults); + } + } + + public void requestPermissions(String[] permissions, PermissionRequester requester) { + ArrayList permissionsToRequest = new ArrayList<>(); + for(String permission: permissions) { + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(permission); + } + } + if (permissionsToRequest.isEmpty()) { + int[] permissionsAlreadyGranted = new int[permissions.length]; + for (int i = 0; i < permissionsAlreadyGranted.length; ++i) + permissionsAlreadyGranted[i] = PackageManager.PERMISSION_GRANTED; + requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted); + return; + } + + int newKey = requester == null ? REQUESTER_NONE : requesters.size(); + if (newKey != REQUESTER_NONE) { + requesters.put(newKey, requester); + } + String[] permissionsCopy = new String[permissionsToRequest.size()]; + permissionsToRequest.toArray(permissionsCopy); + ActivityCompat.requestPermissions(this, permissionsCopy, newKey); + + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt new file mode 100644 index 0000000..6f3d279 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -0,0 +1,225 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.Toast +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.keylesspalace.tusky.components.chat.ChatActivity +import com.keylesspalace.tusky.entity.Chat +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.LinkHelper +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider +import com.uber.autodispose.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers +import java.net.URI +import java.net.URISyntaxException +import javax.inject.Inject + +/** this is the base class for all activities that open links + * links are checked against the api if they are mastodon links so they can be openend in Tusky + * Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierachy + */ + +abstract class BottomSheetActivity : BaseActivity() { + + lateinit var bottomSheet: BottomSheetBehavior + var searchUrl: String? = null + + @Inject + lateinit var mastodonApi: MastodonApi + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + val bottomSheetLayout: LinearLayout = findViewById(R.id.item_status_bottom_sheet) + bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + bottomSheet.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + cancelActiveSearch() + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + }) + + } + + open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) { + if (!looksLikeMastodonUrl(url)) { + openLink(url) + return + } + + mastodonApi.searchObservable( + query = url, + resolve = true + ).observeOn(AndroidSchedulers.mainThread()) + .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ (accounts, statuses) -> + if (getCancelSearchRequested(url)) { + return@subscribe + } + + onEndSearch(url) + + if (accounts.isNotEmpty()) { + + // HACKHACK: Pleroma, remove when search will work normally + if (accounts[0].pleroma != null) { + val account = accounts.firstOrNull { it.pleroma?.apId == url || it.url == url } + + if (account != null) { + viewAccount(account.id) + return@subscribe + } + } else { + viewAccount(accounts[0].id) + return@subscribe + } + } + + if (statuses.isNotEmpty()) { + viewThread(statuses[0].id, statuses[0].url) + return@subscribe + } + + performUrlFallbackAction(url, lookupFallbackBehavior) + }, { + if (!getCancelSearchRequested(url)) { + onEndSearch(url) + performUrlFallbackAction(url, lookupFallbackBehavior) + } + }) + + onBeginSearch(url) + } + + open fun viewThread(statusId: String, url: String?) { + if (!isSearching()) { + val intent = ViewThreadActivity.startIntent(this, statusId, url) + startActivityWithSlideInAnimation(intent) + } + } + + open fun viewAccount(id: String) { + val intent = AccountActivity.getIntent(this, id) + startActivityWithSlideInAnimation(intent) + } + + open fun openChat(chat: Chat) { + startActivityWithSlideInAnimation(ChatActivity.getIntent(this, chat)) + } + + protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) { + when (fallbackBehavior) { + PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url) + PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(this, getString(R.string.post_lookup_error_format, url), Toast.LENGTH_SHORT).show() + } + } + + @VisibleForTesting + fun onBeginSearch(url: String) { + searchUrl = url + showQuerySheet() + } + + @VisibleForTesting + fun getCancelSearchRequested(url: String): Boolean { + return url != searchUrl + } + + @VisibleForTesting + fun isSearching(): Boolean { + return searchUrl != null + } + + @VisibleForTesting + fun onEndSearch(url: String?) { + if (url == searchUrl) { + // Don't clear query if there's no match, + // since we might just now be getting the response for a canceled search + searchUrl = null + hideQuerySheet() + } + } + + @VisibleForTesting + fun cancelActiveSearch() { + if (isSearching()) { + onEndSearch(searchUrl) + } + } + + @VisibleForTesting + open fun openLink(url: String) { + LinkHelper.openLink(url, this) + } + + private fun showQuerySheet() { + bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED + } + + private fun hideQuerySheet() { + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + } +} + +// https://mastodon.foo.bar/@User +// https://mastodon.foo.bar/@User/43456787654678 +// https://pleroma.foo.bar/users/User +// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0 +// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc +// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 +// https://friendica.foo.bar/profile/user +// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207 +// https://misskey.foo.bar/notes/83w6r388br (always lowercase) +fun looksLikeMastodonUrl(urlString: String): Boolean { + val uri: URI + try { + uri = URI(urlString) + } catch (e: URISyntaxException) { + return false + } + + if (uri.query != null || + uri.fragment != null || + uri.path == null) { + return false + } + + val path = uri.path + return path.matches("^/@[^/]+$".toRegex()) || + path.matches("^/@[^/]+/\\d+$".toRegex()) || + path.matches("^/users/\\w+$".toRegex()) || + path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || + path.matches("^/objects/[-a-f0-9]+$".toRegex()) || + path.matches("^/notes/[a-z0-9]+$".toRegex()) || + path.matches("^/display/[-a-f0-9]+$".toRegex()) || + path.matches("^/profile/\\w+$".toRegex()) +} + +enum class PostLookupFallbackBehavior { + OPEN_IN_BROWSER, + DISPLAY_ERROR, +} diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt new file mode 100644 index 0000000..f9b2c5a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -0,0 +1,427 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import androidx.activity.viewModels +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.FitCenter +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import com.theartofdev.edmodo.cropper.CropImage +import kotlinx.android.synthetic.main.activity_edit_profile.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class EditProfileActivity : BaseActivity(), Injectable { + + companion object { + const val AVATAR_SIZE = 400 + const val HEADER_WIDTH = 1500 + const val HEADER_HEIGHT = 500 + + private const val AVATAR_PICK_RESULT = 1 + private const val HEADER_PICK_RESULT = 2 + private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 + private const val MASTODON_MAX_ACCOUNT_FIELDS = 4 + + private const val BUNDLE_CURRENTLY_PICKING = "BUNDLE_CURRENTLY_PICKING" + } + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: EditProfileViewModel by viewModels { viewModelFactory } + + private var currentlyPicking: PickType = PickType.NOTHING + + private val accountFieldEditAdapter = AccountFieldEditAdapter() + private var maxAccountFields = MASTODON_MAX_ACCOUNT_FIELDS + + private enum class PickType { + NOTHING, + AVATAR, + HEADER + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + savedInstanceState?.getString(BUNDLE_CURRENTLY_PICKING)?.let { + currentlyPicking = PickType.valueOf(it) + } + + setContentView(R.layout.activity_edit_profile) + + setSupportActionBar(toolbar) + supportActionBar?.run { + setTitle(R.string.title_edit_profile) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) } + headerButton.setOnClickListener { onMediaPick(PickType.HEADER) } + + fieldList.layoutManager = LinearLayoutManager(this) + fieldList.adapter = accountFieldEditAdapter + + val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { sizeDp = 12; colorInt = Color.WHITE } + + addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(plusDrawable, null, null, null) + + addFieldButton.setOnClickListener { + accountFieldEditAdapter.addField() + if(accountFieldEditAdapter.itemCount >= maxAccountFields) { + it.isVisible = false + } + + scrollView.post{ + scrollView.smoothScrollTo(0, it.bottom) + } + } + + viewModel.obtainProfile() + + viewModel.profileData.observe(this) { profileRes -> + when (profileRes) { + is Success -> { + val me = profileRes.data + if (me != null) { + + displayNameEditText.setText(me.displayName) + noteEditText.setText(me.source?.note) + lockedCheckBox.isChecked = me.locked + + accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) + addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < maxAccountFields + + if(viewModel.avatarData.value == null) { + Glide.with(this) + .load(me.avatar) + .placeholder(R.drawable.avatar_default) + .transform( + FitCenter(), + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) + ) + .into(avatarPreview) + } + + if(viewModel.headerData.value == null) { + Glide.with(this) + .load(me.header) + .into(headerPreview) + } + + } + } + is Error -> { + val snackbar = Snackbar.make(avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG) + snackbar.setAction(R.string.action_retry) { + viewModel.obtainProfile() + } + snackbar.show() + + } + } + } + + viewModel.obtainInstance() + viewModel.instanceData.observe(this) { result -> + when (result) { + is Success -> { + val instance = result.data + if (instance?.maxBioChars != null && instance.maxBioChars > 0) { + noteEditTextLayout.counterMaxLength = instance.maxBioChars + } + + instance?.pleroma?.metadata?.fieldsLimits?.let { + maxAccountFields = it.maxFields + + if(maxAccountFields > MASTODON_MAX_ACCOUNT_FIELDS + && accountFieldEditAdapter.itemCount == MASTODON_MAX_ACCOUNT_FIELDS + && !addFieldButton.isEnabled) { + addFieldButton.isEnabled = true + } + } + } + } + } + + observeImage(viewModel.avatarData, avatarPreview, avatarProgressBar, true) + observeImage(viewModel.headerData, headerPreview, headerProgressBar, false) + + viewModel.saveData.observe(this) { + when(it) { + is Success -> { + finish() + } + is Loading -> { + saveProgressBar.visibility = View.VISIBLE + } + is Error -> { + onSaveFailure(it.errorMessage) + } + } + } + + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(BUNDLE_CURRENTLY_PICKING, currentlyPicking.toString()) + } + + override fun onStop() { + super.onStop() + if(!isFinishing) { + viewModel.updateProfile(displayNameEditText.text.toString(), + noteEditText.text.toString(), + lockedCheckBox.isChecked, + accountFieldEditAdapter.getFieldData()) + } + } + + private fun observeImage(liveData: LiveData>, + imageView: ImageView, + progressBar: View, + roundedCorners: Boolean) { + liveData.observe(this, Observer> { + + when (it) { + is Success -> { + val glide = Glide.with(imageView) + .load(it.data) + + if (roundedCorners) { + glide.transform( + FitCenter(), + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) + ) + } + + glide.into(imageView) + + imageView.show() + progressBar.hide() + } + is Loading -> { + progressBar.show() + } + is Error -> { + progressBar.hide() + if(!it.consumed) { + onResizeFailure() + it.consumed = true + } + + } + } + }) + } + + private fun onMediaPick(pickType: PickType) { + if (currentlyPicking != PickType.NOTHING) { + // Ignore inputs if another pick operation is still occurring. + return + } + currentlyPicking = pickType + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + } else { + initiateMediaPicking() + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, + grantResults: IntArray) { + when (requestCode) { + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initiateMediaPicking() + } else { + endMediaPicking() + Snackbar.make(avatarButton, R.string.error_media_upload_permission, Snackbar.LENGTH_LONG).show() + } + } + } + } + + private fun initiateMediaPicking() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "image/*" + when (currentlyPicking) { + PickType.AVATAR -> { + startActivityForResult(intent, AVATAR_PICK_RESULT) + } + PickType.HEADER -> { + startActivityForResult(intent, HEADER_PICK_RESULT) + } + PickType.NOTHING -> { /* do nothing */ } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.edit_profile_toolbar, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + R.id.action_save -> { + save() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun save() { + if (currentlyPicking != PickType.NOTHING) { + return + } + + viewModel.save(displayNameEditText.text.toString(), + noteEditText.text.toString(), + lockedCheckBox.isChecked, + accountFieldEditAdapter.getFieldData(), + this) + } + + private fun onSaveFailure(msg: String?) { + val errorMsg = msg ?: getString(R.string.error_media_upload_sending) + Snackbar.make(avatarButton, errorMsg, Snackbar.LENGTH_LONG).show() + saveProgressBar.visibility = View.GONE + } + + private fun beginMediaPicking() { + when (currentlyPicking) { + PickType.AVATAR -> { + avatarProgressBar.visibility = View.VISIBLE + avatarPreview.visibility = View.INVISIBLE + avatarButton.setImageDrawable(null) + + } + PickType.HEADER -> { + headerProgressBar.visibility = View.VISIBLE + headerPreview.visibility = View.INVISIBLE + headerButton.setImageDrawable(null) + } + PickType.NOTHING -> { /* do nothing */ } + } + } + + private fun endMediaPicking() { + avatarProgressBar.visibility = View.GONE + headerProgressBar.visibility = View.GONE + + currentlyPicking = PickType.NOTHING + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + AVATAR_PICK_RESULT -> { + if (resultCode == Activity.RESULT_OK && data != null) { + CropImage.activity(data.data) + .setInitialCropWindowPaddingRatio(0f) + .setOutputCompressFormat(Bitmap.CompressFormat.PNG) + .setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) + .start(this) + } else { + endMediaPicking() + } + } + HEADER_PICK_RESULT -> { + if (resultCode == Activity.RESULT_OK && data != null) { + CropImage.activity(data.data) + .setInitialCropWindowPaddingRatio(0f) + .setOutputCompressFormat(Bitmap.CompressFormat.PNG) + .setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) + .start(this) + } else { + endMediaPicking() + } + } + CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> { + val result = CropImage.getActivityResult(data) + when (resultCode) { + Activity.RESULT_OK -> beginResize(result.uri) + CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure() + else -> endMediaPicking() + } + } + } + } + + private fun beginResize(uri: Uri) { + beginMediaPicking() + + when (currentlyPicking) { + PickType.AVATAR -> { + viewModel.newAvatar(uri, this) + } + PickType.HEADER -> { + viewModel.newHeader(uri, this) + } + else -> { + throw AssertionError("PickType not set.") + } + } + + currentlyPicking = PickType.NOTHING + + } + + private fun onResizeFailure() { + Snackbar.make(avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() + endMediaPicking() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt new file mode 100644 index 0000000..55a856c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt @@ -0,0 +1,219 @@ +package com.keylesspalace.tusky + +import android.os.Bundle +import android.view.MenuItem +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.android.synthetic.main.activity_filters.* +import kotlinx.android.synthetic.main.dialog_filter.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject + +class FiltersActivity: BaseActivity() { + @Inject + lateinit var api: MastodonApi + + @Inject + lateinit var eventHub: EventHub + + private lateinit var context : String + private lateinit var filters: MutableList + private lateinit var dialog: AlertDialog + + companion object { + const val FILTERS_CONTEXT = "filters_context" + const val FILTERS_TITLE = "filters_title" + } + + private fun updateFilter(filter: Filter, itemIndex: Int) { + api.updateFilter(filter.id, MastodonApi.PostFilter(filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt)) + .enqueue(object: Callback{ + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show() + } + + override fun onResponse(call: Call, response: Response) { + val updatedFilter = response.body()!! + if (updatedFilter.context.contains(context)) { + filters[itemIndex] = updatedFilter + } else { + filters.removeAt(itemIndex) + } + refreshFilterDisplay() + eventHub.dispatch(PreferenceChangedEvent(context)) + } + }) + } + + private fun deleteFilter(itemIndex: Int) { + val filter = filters[itemIndex] + if (filter.context.size == 1) { + // This is the only context for this filter; delete it + api.deleteFilter(filters[itemIndex].id).enqueue(object: Callback { + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() + } + + override fun onResponse(call: Call, response: Response) { + filters.removeAt(itemIndex) + refreshFilterDisplay() + eventHub.dispatch(PreferenceChangedEvent(context)) + } + }) + } else { + // Keep the filter, but remove it from this context + val oldFilter = filters[itemIndex] + val newFilter = Filter(oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, + oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord) + updateFilter(newFilter, itemIndex) + } + } + + private fun createFilter(phrase: String, wholeWord: Boolean) { + api.createFilter(MastodonApi.PostFilter(phrase, listOf(context), false, wholeWord, "")) + .enqueue(object: Callback { + override fun onResponse(call: Call, response: Response) { + val filterResponse = response.body() + if(response.isSuccessful && filterResponse != null) { + filters.add(filterResponse) + refreshFilterDisplay() + eventHub.dispatch(PreferenceChangedEvent(context)) + } else { + Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show() + } + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show() + } + }) + } + + private fun showAddFilterDialog() { + dialog = AlertDialog.Builder(this@FiltersActivity) + .setTitle(R.string.filter_addition_dialog_title) + .setView(R.layout.dialog_filter) + .setPositiveButton(android.R.string.ok){ _, _ -> + createFilter(dialog.phraseEditText.text.toString(), dialog.phraseWholeWord.isChecked) + } + .setNeutralButton(android.R.string.cancel, null) + .create() + dialog.show() + dialog.phraseWholeWord.isChecked = true + } + + private fun setupEditDialogForItem(itemIndex: Int) { + dialog = AlertDialog.Builder(this@FiltersActivity) + .setTitle(R.string.filter_edit_dialog_title) + .setView(R.layout.dialog_filter) + .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> + val oldFilter = filters[itemIndex] + val newFilter = Filter(oldFilter.id, dialog.phraseEditText.text.toString(), oldFilter.context, + oldFilter.expiresAt, oldFilter.irreversible, dialog.phraseWholeWord.isChecked) + updateFilter(newFilter, itemIndex) + } + .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> + deleteFilter(itemIndex) + } + .setNeutralButton(android.R.string.cancel, null) + .create() + dialog.show() + + // Need to show the dialog before referencing any elements from its view + val filter = filters[itemIndex] + dialog.phraseEditText.setText(filter.phrase) + dialog.phraseWholeWord.isChecked = filter.wholeWord + } + + private fun refreshFilterDisplay() { + filtersView.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, filters.map { filter -> filter.phrase }) + filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForItem(position) } + } + + private fun loadFilters() { + + filterMessageView.hide() + filtersView.hide() + addFilterButton.hide() + filterProgressBar.show() + + api.getFilters().enqueue(object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + val filterResponse = response.body() + if(response.isSuccessful && filterResponse != null) { + + filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList() + refreshFilterDisplay() + + filtersView.show() + addFilterButton.show() + filterProgressBar.hide() + } else { + filterProgressBar.hide() + filterMessageView.show() + filterMessageView.setup(R.drawable.elephant_error, + R.string.error_generic) { loadFilters() } + } + } + + override fun onFailure(call: Call>, t: Throwable) { + filterProgressBar.hide() + filterMessageView.show() + if (t is IOException) { + filterMessageView.setup(R.drawable.elephant_offline, + R.string.error_network) { loadFilters() } + } else { + filterMessageView.setup(R.drawable.elephant_error, + R.string.error_generic) { loadFilters() } + } + } + }) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_filters) + setupToolbarBackArrow() + addFilterButton.setOnClickListener { + showAddFilterDialog() + } + + title = intent?.getStringExtra(FILTERS_TITLE) + context = intent?.getStringExtra(FILTERS_CONTEXT)!! + loadFilters() + } + + private fun setupToolbarBackArrow() { + setSupportActionBar(toolbar) + supportActionBar?.run { + // Back button + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + } + + // Activate back arrow in toolbar + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt new file mode 100644 index 0000000..915baf9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -0,0 +1,84 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.os.Bundle +import androidx.annotation.RawRes +import android.util.Log +import android.view.MenuItem +import android.widget.TextView +import com.keylesspalace.tusky.util.IOUtils +import kotlinx.android.extensions.CacheImplementation +import kotlinx.android.extensions.ContainerOptions +import kotlinx.android.synthetic.main.activity_license.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader + +class LicenseActivity : BaseActivity() { + + @ContainerOptions(cache = CacheImplementation.NO_CACHE) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_license) + + setSupportActionBar(toolbar) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + setTitle(R.string.title_licenses) + + loadFileIntoTextView(R.raw.apache, licenseApacheTextView) + + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { + + val sb = StringBuilder() + + val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId))) + + try { + var line: String? = br.readLine() + while (line != null) { + sb.append(line) + sb.append('\n') + line = br.readLine() + } + } catch (e: IOException) { + Log.w("LicenseActivity", e) + } + + IOUtils.closeQuietly(br) + + textView.text = sb.toString() + + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt new file mode 100644 index 0000000..f5e7641 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -0,0 +1,287 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky + +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.* +import androidx.recyclerview.widget.ListAdapter +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewmodel.ListsViewModel +import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.* +import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.* +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.color +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import com.mikepenz.iconics.utils.toIconicsColor +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.activity_lists.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +/** + * Created by charlag on 1/4/18. + */ + +class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { + + companion object { + @JvmStatic + fun newIntent(context: Context): Intent { + return Intent(context, ListsActivity::class.java) + } + } + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + + private lateinit var viewModel: ListsViewModel + private val adapter = ListsAdapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_lists) + + + setSupportActionBar(toolbar) + supportActionBar?.apply { + title = getString(R.string.title_lists) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + listsRecycler.adapter = adapter + listsRecycler.layoutManager = LinearLayoutManager(this) + listsRecycler.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + + viewModel = viewModelFactory.create(ListsViewModel::class.java) + viewModel.state + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe(this::update) + viewModel.retryLoading() + + addListButton.setOnClickListener { + showlistNameDialog(null) + } + + viewModel.events.observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe { event -> + @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") + when (event) { + CREATE_ERROR -> showMessage(R.string.error_create_list) + RENAME_ERROR -> showMessage(R.string.error_rename_list) + DELETE_ERROR -> showMessage(R.string.error_delete_list) + } + } + } + + private fun showlistNameDialog(list: MastoList?) { + val layout = FrameLayout(this) + val editText = EditText(this) + editText.setHint(R.string.hint_list_name) + layout.addView(editText) + val margin = Utils.dpToPx(this, 8) + (editText.layoutParams as ViewGroup.MarginLayoutParams) + .setMargins(margin, margin, margin, 0) + + val dialog = AlertDialog.Builder(this) + .setView(layout) + .setPositiveButton( + if (list == null) R.string.action_create_list + else R.string.action_rename_list) { _, _ -> + onPickedDialogName(editText.text, list?.id) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + + val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) + editText.onTextChanged { s, _, _, _ -> + positiveButton.isEnabled = !s.isNullOrBlank() + } + editText.setText(list?.title) + editText.text?.let { editText.setSelection(it.length) } + } + + private fun showListDeleteDialog(list: MastoList) { + AlertDialog.Builder(this) + .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) + .setPositiveButton(R.string.action_delete){ _, _ -> + viewModel.deleteList(list.id) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + + private fun update(state: ListsViewModel.State) { + adapter.submitList(state.lists) + progressBar.visible(state.loadingState == LOADING) + when (state.loadingState) { + INITIAL, LOADING -> messageView.hide() + ERROR_NETWORK -> { + messageView.show() + messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + viewModel.retryLoading() + } + } + ERROR_OTHER -> { + messageView.show() + messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + viewModel.retryLoading() + } + } + LOADED -> + if (state.lists.isEmpty()) { + messageView.show() + messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, + null) + } else { + messageView.hide() + } + } + } + + private fun showMessage(@StringRes messageId: Int) { + Snackbar.make( + listsRecycler, messageId, Snackbar.LENGTH_SHORT + ).show() + + } + + private fun onListSelected(listId: String) { + startActivityWithSlideInAnimation( + ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId)) + } + + private fun openListSettings(list: MastoList) { + AccountsInListFragment.newInstance(list.id, list.title).show(supportFragmentManager, null) + } + + private fun renameListDialog(list: MastoList) { + showlistNameDialog(list) + } + + private fun onMore(list: MastoList, view: View) { + PopupMenu(view.context, view).apply { + inflate(R.menu.list_actions) + setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.list_edit -> openListSettings(list) + R.id.list_rename -> renameListDialog(list) + R.id.list_delete -> showListDeleteDialog(list) + else -> return@setOnMenuItemClickListener false + } + true + } + show() + } + } + + override fun androidInjector() = dispatchingAndroidInjector + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + return false + } + + private object ListsDiffer : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { + return oldItem == newItem + } + } + + private inner class ListsAdapter + : ListAdapter(ListsDiffer) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { + return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) + .let(this::ListViewHolder) + .apply { + val context = nameTextView.context + val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary) + val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor } + + nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + } + } + + override fun onBindViewHolder(holder: ListViewHolder, position: Int) { + holder.nameTextView.text = getItem(position).title + } + + private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view), + View.OnClickListener { + val nameTextView: TextView = view.findViewById(R.id.list_name_textview) + val moreButton: ImageButton = view.findViewById(R.id.editListButton) + + init { + view.setOnClickListener(this) + moreButton.setOnClickListener(this) + } + + override fun onClick(v: View) { + if (v == itemView) { + onListSelected(getItem(adapterPosition).id) + } else { + onMore(getItem(adapterPosition), v) + } + } + } + } + + private fun onPickedDialogName(name: CharSequence, listId: String?) { + if (listId == null) { + viewModel.createNewList(name.toString()) + } else { + viewModel.renameList(listId, name.toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt new file mode 100644 index 0000000..92355f5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt @@ -0,0 +1,389 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.AccessToken +import com.keylesspalace.tusky.entity.AppCredentials +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.getNonNullString +import kotlinx.android.synthetic.main.activity_login.* +import okhttp3.HttpUrl +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import javax.inject.Inject + +class LoginActivity : BaseActivity(), Injectable { + + @Inject + lateinit var mastodonApi: MastodonApi + + private lateinit var preferences: SharedPreferences + + private val oauthRedirectUri: String + get() { + val scheme = getString(R.string.oauth_scheme) + val host = BuildConfig.APPLICATION_ID + return "$scheme://$host/" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_login) + + if(savedInstanceState == null ) { + if(BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) { + domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) + domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) + } + appNameEditText.setText(getString(R.string.app_name)) + appNameEditText.setSelection(getString(R.string.app_name).length) + + websiteEditText.setText(getString(R.string.tusky_website)) + websiteEditText.setSelection(getString(R.string.tusky_website).length) + } + + if(BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { + Glide.with(loginLogo) + .load(BuildConfig.CUSTOM_LOGO_URL) + .placeholder(null) + .into(loginLogo) + } + + preferences = getSharedPreferences( + getString(R.string.preferences_file_key), Context.MODE_PRIVATE) + + loginButton.setOnClickListener { onButtonClick() } + settingsButton.setOnClickListener { onSettingsButtonClick() } + + whatsAnInstanceTextView.setOnClickListener { + val dialog = AlertDialog.Builder(this) + .setMessage(R.string.dialog_whats_an_instance) + .setPositiveButton(R.string.action_close, null) + .show() + val textView = dialog.findViewById(android.R.id.message) + textView?.movementMethod = LinkMovementMethod.getInstance() + } + + if (isAdditionalLogin()) { + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowTitleEnabled(false) + } else { + toolbar.visibility = View.GONE + } + + } + + override fun requiresLogin(): Boolean { + return false + } + + override fun finish() { + super.finish() + if(isAdditionalLogin()) { + overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun onSettingsButtonClick() { + if(extendedSettings.visibility == View.GONE) { + extendedSettings.visibility = View.VISIBLE + } else { + extendedSettings.visibility = View.GONE + } + + } + + /** + * Obtain the oauth client credentials for this app. This is only necessary the first time the + * app is run on a given server instance. So, after the first authentication, they are + * saved in SharedPreferences and every subsequent run they are simply fetched from there. + */ + private fun onButtonClick() { + + loginButton.isEnabled = false + + val domain = canonicalizeDomain(domainEditText.text.toString()) + + try { + HttpUrl.Builder().host(domain).scheme("https").build() + } catch (e: IllegalArgumentException) { + setLoading(false) + domainTextInputLayout.error = getString(R.string.error_invalid_domain) + return + } + + val callback = object : Callback { + override fun onResponse(call: Call, + response: Response) { + if (!response.isSuccessful) { + loginButton.isEnabled = true + domainTextInputLayout.error = getString(R.string.error_failed_app_registration) + setLoading(false) + Log.e(TAG, "App authentication failed. " + response.message()) + return + } + val credentials = response.body() + val clientId = credentials!!.clientId + val clientSecret = credentials.clientSecret + + preferences.edit() + .putString("domain", domain) + .putString("clientId", clientId) + .putString("clientSecret", clientSecret) + .apply() + + redirectUserToAuthorizeAndLogin(domain, clientId) + } + + override fun onFailure(call: Call, t: Throwable) { + loginButton.isEnabled = true + domainTextInputLayout.error = getString(R.string.error_failed_app_registration) + setLoading(false) + Log.e(TAG, Log.getStackTraceString(t)) + } + } + + var appname = getString(R.string.app_name) + var website = getString(R.string.tusky_website) + if(extendedSettings.visibility == View.VISIBLE) { + appname = appNameEditText.text.toString() + website = websiteEditText.text.toString() + } + + mastodonApi + .authenticateApp(domain, appname, oauthRedirectUri, + OAUTH_SCOPES, website) + .enqueue(callback) + setLoading(true) + + } + + private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { + /* To authorize this app and log in it's necessary to redirect to the domain given, + * login there, and the server will redirect back to the app with its response. */ + val endpoint = MastodonApi.ENDPOINT_AUTHORIZE + val parameters = mapOf( + "client_id" to clientId, + "redirect_uri" to oauthRedirectUri, + "response_type" to "code", + "scope" to OAUTH_SCOPES + ) + val url = "https://" + domain + endpoint + "?" + toQueryString(parameters) + val uri = Uri.parse(url) + if (!openInCustomTab(uri, this)) { + val viewIntent = Intent(Intent.ACTION_VIEW, uri) + if (viewIntent.resolveActivity(packageManager) != null) { + startActivity(viewIntent) + } else { + domainEditText.error = getString(R.string.error_no_web_browser_found) + setLoading(false) + } + } + } + + override fun onStart() { + super.onStart() + /* Check if we are resuming during authorization by seeing if the intent contains the + * redirect that was given to the server. If so, its response is here! */ + val uri = intent.data + val redirectUri = oauthRedirectUri + + if (uri != null && uri.toString().startsWith(redirectUri)) { + // This should either have returned an authorization code or an error. + val code = uri.getQueryParameter("code") + val error = uri.getQueryParameter("error") + + /* restore variables from SharedPreferences */ + val domain = preferences.getNonNullString(DOMAIN, "") + val clientId = preferences.getNonNullString(CLIENT_ID, "") + val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "") + + if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) { + + setLoading(true) + /* Since authorization has succeeded, the final step to log in is to exchange + * the authorization code for an access token. */ + val callback = object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + onLoginSuccess(response.body()!!.accessToken, domain) + } else { + setLoading(false) + domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) + Log.e(TAG, String.format("%s %s", + getString(R.string.error_retrieving_oauth_token), + response.message())) + } + } + + override fun onFailure(call: Call, t: Throwable) { + setLoading(false) + domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) + Log.e(TAG, String.format("%s %s", + getString(R.string.error_retrieving_oauth_token), + t.message)) + } + } + + mastodonApi.fetchOAuthToken(domain, clientId, clientSecret, redirectUri, code, + "authorization_code").enqueue(callback) + } else if (error != null) { + /* Authorization failed. Put the error response where the user can read it and they + * can try again. */ + setLoading(false) + domainTextInputLayout.error = getString(R.string.error_authorization_denied) + Log.e(TAG, String.format("%s %s", + getString(R.string.error_authorization_denied), + error)) + } else { + // This case means a junk response was received somehow. + setLoading(false) + domainTextInputLayout.error = getString(R.string.error_authorization_unknown) + } + } else { + // first show or user cancelled login + setLoading(false) + } + } + + private fun setLoading(loadingState: Boolean) { + if (loadingState) { + loginLoadingLayout.visibility = View.VISIBLE + loginInputLayout.visibility = View.GONE + } else { + loginLoadingLayout.visibility = View.GONE + loginInputLayout.visibility = View.VISIBLE + loginButton.isEnabled = true + } + } + + private fun isAdditionalLogin(): Boolean { + return intent.getBooleanExtra(LOGIN_MODE, false) + } + + private fun onLoginSuccess(accessToken: String, domain: String) { + + setLoading(true) + + accountManager.addAccount(accessToken, domain) + + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + overridePendingTransition(R.anim.explode, R.anim.explode) + } + + companion object { + private const val TAG = "LoginActivity" // logging tag + private const val OAUTH_SCOPES = "read write follow" + private const val LOGIN_MODE = "LOGIN_MODE" + private const val DOMAIN = "domain" + private const val CLIENT_ID = "clientId" + private const val CLIENT_SECRET = "clientSecret" + + @JvmStatic + fun getIntent(context: Context, mode: Boolean): Intent { + val loginIntent = Intent(context, LoginActivity::class.java) + loginIntent.putExtra(LOGIN_MODE, mode) + return loginIntent + } + + /** Make sure the user-entered text is just a fully-qualified domain name. */ + private fun canonicalizeDomain(domain: String): String { + // Strip any schemes out. + var s = domain.replaceFirst("http://", "") + s = s.replaceFirst("https://", "") + // If a username was included (e.g. username@example.com), just take what's after the '@'. + val at = s.lastIndexOf('@') + if (at != -1) { + s = s.substring(at + 1) + } + return s.trim { it <= ' ' } + } + + /** + * Chain together the key-value pairs into a query string, for either appending to a URL or + * as the content of an HTTP request. + */ + private fun toQueryString(parameters: Map): String { + val s = StringBuilder() + var between = "" + for ((key, value) in parameters) { + s.append(between) + s.append(Uri.encode(key)) + s.append("=") + s.append(Uri.encode(value)) + between = "&" + } + return s.toString() + } + + private fun openInCustomTab(uri: Uri, context: Context): Boolean { + + val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface) + val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor) + val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor) + + val colorSchemeParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(toolbarColor) + .setNavigationBarColor(navigationbarColor) + .setNavigationBarDividerColor(navigationbarDividerColor) + .build() + + val customTabsIntent = CustomTabsIntent.Builder() + .setDefaultColorSchemeParams(colorSchemeParams) + .build() + + try { + customTabsIntent.launchUrl(context, uri) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "Activity was not found for intent $customTabsIntent") + return false + } + + return true + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt new file mode 100644 index 0000000..dd0dd79 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -0,0 +1,836 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import androidx.appcompat.app.AlertDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.emoji.text.EmojiCompat +import androidx.emoji.text.EmojiCompat.InitCallback +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.viewpager2.widget.MarginPageTransformer +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.FixedSizeDrawable +import com.bumptech.glide.request.transition.Transition +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType +import com.keylesspalace.tusky.components.conversation.ConversationsRepository +import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.preference.PreferencesActivity +import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity +import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.pager.MainPagerAdapter +import com.keylesspalace.tusky.service.StreamingService +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import com.mikepenz.materialdrawer.holder.BadgeStyle +import com.mikepenz.materialdrawer.holder.ColorHolder +import com.mikepenz.materialdrawer.holder.StringHolder +import com.mikepenz.materialdrawer.iconics.iconicsIcon +import com.mikepenz.materialdrawer.model.* +import com.mikepenz.materialdrawer.model.interfaces.* +import com.mikepenz.materialdrawer.util.* +import com.mikepenz.materialdrawer.widget.AccountHeaderView +import com.uber.autodispose.android.lifecycle.autoDispose +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.activity_main.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject + +class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var cacheUpdater: CacheUpdater + + @Inject + lateinit var conversationRepository: ConversationsRepository + + @Inject + lateinit var appDb: AppDatabase + + private lateinit var header: AccountHeaderView + + private var notificationTabPosition = 0 + private var onTabSelectedListener: OnTabSelectedListener? = null + + private var unreadAnnouncementsCount = 0 + + private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + + private lateinit var glide: RequestManager + + private val emojiInitCallback = object : InitCallback() { + override fun onInitialized() { + if (!isDestroyed) { + updateProfiles() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val activeAccount = accountManager.activeAccount + if (activeAccount == null) { + // will be redirected to LoginActivity by BaseActivity + return + } + var showNotificationTab = false + if (intent != null) { + /** there are two possibilities the accountId can be passed to MainActivity: + * - from our code as long 'account_id' + * - from share shortcuts as String 'android.intent.extra.shortcut.ID' + */ + var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) + if (accountId == -1L) { + val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + if (accountIdString != null) { + accountId = accountIdString.toLong() + } + } + val accountRequested = accountId != -1L + if (accountRequested && accountId != activeAccount.id) { + accountManager.setActiveAccount(accountId) + } + if (canHandleMimeType(intent.type)) { + // Sharing to Tusky from an external app + if (accountRequested) { + // The correct account is already active + forwardShare(intent) + } else { + // No account was provided, show the chooser + showAccountChooserDialog(getString(R.string.action_share_as), true, object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + val requestedId = account.id + if (requestedId == activeAccount.id) { + // The correct account is already active + forwardShare(intent) + } else { + // A different account was requested, restart the activity + intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) + changeAccount(requestedId, intent) + } + } + }) + } + } else if (accountRequested) { + // user clicked a notification, show notification tab and switch user if necessary + showNotificationTab = true + } + } + window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own + setContentView(R.layout.activity_main) + ViewPager2Fix.reduceVelocity(viewPager, 2.0f); + + glide = Glide.with(this) + + composeButton.setOnClickListener { + val composeIntent = Intent(applicationContext, ComposeActivity::class.java) + startActivity(composeIntent) + } + + val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) + mainToolbar.visible(!hideTopToolbar) + + loadDrawerAvatar(activeAccount.profilePictureUrl, true) + + mainToolbar.menu.add(R.string.action_search).apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = ThemeUtils.getColor(this@MainActivity, android.R.attr.textColorPrimary) + } + setOnMenuItemClickListener { + startActivity(SearchActivity.getIntent(this@MainActivity)) + true + } + } + + setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar) + + /* Fetch user info while we're doing other things. This has to be done after setting up the + * drawer, though, because its callback touches the header in the drawer. */ + fetchUserInfo() + + fetchAnnouncements() + + setupTabs(showNotificationTab) + + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event: Event? -> + when (event) { + is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) + is MainTabsChangedEvent -> setupTabs(false) + is PreferenceChangedEvent -> { + when (event.preferenceKey) { + PrefKeys.LIVE_NOTIFICATIONS -> { + initPullNotifications() + } + } + } + is AnnouncementReadEvent -> { + unreadAnnouncementsCount-- + updateAnnouncementsBadge() + } + } + } + + Schedulers.io().scheduleDirect { + // Flush old media that was cached for sharing + deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Husky")) + } + } + + private fun initPullNotifications() { + if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { + if(accountManager.areNotificationsStreamingEnabled()) { + StreamingService.startStreaming(this) + NotificationHelper.disablePullNotifications(this) + } else { + StreamingService.stopStreaming(this) + NotificationHelper.enablePullNotifications(this) + } + } else { + StreamingService.stopStreaming(this) + NotificationHelper.disablePullNotifications(this) + } + draftWarning() + } + + override fun onResume() { + super.onResume() + NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) + } + + override fun onBackPressed() { + when { + mainDrawerLayout.isOpen -> { + mainDrawerLayout.close() + } + viewPager.currentItem != 0 -> { + viewPager.currentItem = 0 + } + else -> { + super.onBackPressed() + } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_MENU -> { + if (mainDrawerLayout.isOpen) { + mainDrawerLayout.close() + } else { + mainDrawerLayout.open() + } + return true + } + KeyEvent.KEYCODE_SEARCH -> { + startActivityWithSlideInAnimation(SearchActivity.getIntent(this)) + return true + } + } + if (event.isCtrlPressed || event.isShiftPressed) { + // FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED + when (keyCode) { + KeyEvent.KEYCODE_N -> { + + // open compose activity by pressing SHIFT + N (or CTRL + N) + val composeIntent = Intent(applicationContext, ComposeActivity::class.java) + startActivity(composeIntent) + return true + } + } + } + return super.onKeyDown(keyCode, event) + } + + public override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + if (intent != null) { + val statusUrl = intent.getStringExtra(STATUS_URL) + if (statusUrl != null) { + viewUrl(statusUrl, PostLookupFallbackBehavior.DISPLAY_ERROR) + } + } + } + + override fun onDestroy() { + super.onDestroy() + EmojiCompat.get().unregisterInitCallback(emojiInitCallback) + } + + private fun forwardShare(intent: Intent) { + val composeIntent = Intent(this, ComposeActivity::class.java) + composeIntent.action = intent.action + composeIntent.type = intent.type + composeIntent.putExtras(intent) + composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(composeIntent) + finish() + } + + private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) { + + mainToolbar.setNavigationOnClickListener { + mainDrawerLayout.open() + } + + header = AccountHeaderView(this).apply { + headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP + currentHiddenInList = true + onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) } + addProfile(ProfileSettingDrawerItem().apply { + identifier = DRAWER_ITEM_ADD_ACCOUNT + nameRes = R.string.add_account_name + descriptionRes = R.string.add_account_description + iconicsIcon = GoogleMaterial.Icon.gmd_add + }, 0) + attachToSliderView(mainDrawer) + dividerBelowHeader = false + closeDrawerOnProfileListClick = true + } + + header.accountHeaderBackground.setColorFilter(ContextCompat.getColor(this, R.color.headerBackgroundFilter)) + header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent)) + val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + + DrawerImageLoader.init(object : AbstractDrawerImageLoader() { + override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { + if (animateAvatars) { + glide.load(uri) + .placeholder(placeholder) + .into(imageView) + } else { + glide.asBitmap() + .load(uri) + .placeholder(placeholder) + .into(imageView) + } + } + + override fun cancel(imageView: ImageView) { + glide.clear(imageView) + } + + override fun placeholder(ctx: Context, tag: String?): Drawable { + if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) { + return ctx.getDrawable(R.drawable.avatar_default)!! + } + + return super.placeholder(ctx, tag) + } + }) + + mainDrawer.apply { + tintStatusBar = true + addItems( + primaryDrawerItem { + nameRes = R.string.action_edit_profile + iconicsIcon = GoogleMaterial.Icon.gmd_person + onClick = { + val intent = Intent(context, EditProfileActivity::class.java) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_view_favourites + isSelectable = false + iconicsIcon = GoogleMaterial.Icon.gmd_star + onClick = { + val intent = StatusListActivity.newFavouritesIntent(context) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_view_bookmarks + iconicsIcon = GoogleMaterial.Icon.gmd_bookmark + onClick = { + val intent = StatusListActivity.newBookmarksIntent(context) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_lists + iconicsIcon = GoogleMaterial.Icon.gmd_list + onClick = { + startActivityWithSlideInAnimation(ListsActivity.newIntent(context)) + } + }, + primaryDrawerItem { + nameRes = R.string.action_access_saved_toot + iconRes = R.drawable.ic_notebook + onClick = { + val intent = DraftsActivity.newIntent(context) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_access_scheduled_toot + iconRes = R.drawable.ic_access_time + onClick = { + startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) + } + }, + primaryDrawerItem { + identifier = DRAWER_ITEM_ANNOUNCEMENTS + nameRes = R.string.title_announcements + iconRes = R.drawable.ic_bullhorn_24dp + onClick = { + startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) + } + badgeStyle = BadgeStyle().apply { + textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary)) + } + }, + DividerDrawerItem(), + secondaryDrawerItem { + nameRes = R.string.action_view_account_preferences + iconRes = R.drawable.ic_account_settings + onClick = { + val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES) + startActivityWithSlideInAnimation(intent) + } + }, + secondaryDrawerItem { + nameRes = R.string.action_view_preferences + iconicsIcon = GoogleMaterial.Icon.gmd_settings + onClick = { + val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES) + startActivityWithSlideInAnimation(intent) + } + }, + secondaryDrawerItem { + nameRes = R.string.about_title_activity + iconicsIcon = GoogleMaterial.Icon.gmd_info + onClick = { + val intent = Intent(context, AboutActivity::class.java) + startActivityWithSlideInAnimation(intent) + } + }, + secondaryDrawerItem { + nameRes = R.string.action_logout + iconRes = R.drawable.ic_logout + onClick = ::logout + } + ) + + if (addSearchButton) { + mainDrawer.addItemsAtPosition(4, + primaryDrawerItem { + nameRes = R.string.action_search + iconicsIcon = GoogleMaterial.Icon.gmd_search + onClick = { + startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) + } + }) + } + + setSavedInstance(savedInstanceState) + } + + if (BuildConfig.DEBUG) { + mainDrawer.addItems( + secondaryDrawerItem { + nameText = "debug" + isEnabled = false + textColor = ColorStateList.valueOf(Color.GREEN) + } + ) + } + EmojiCompat.get().registerInitCallback(emojiInitCallback) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(mainDrawer.saveInstanceState(outState)) + } + + private fun setupTabs(selectNotificationTab: Boolean) { + + val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { + val actionBarSize = ThemeUtils.getDimension(this, R.attr.actionBarSize) + val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) + (composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin + tabLayout.hide() + bottomTabLayout + } else { + bottomNav.hide() + (viewPager.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = 0 + (composeButton.layoutParams as CoordinatorLayout.LayoutParams).anchorId = R.id.viewPager + tabLayout + } + + val tabs = accountManager.activeAccount!!.tabPreferences + + val adapter = MainPagerAdapter(tabs, this) + viewPager.adapter = adapter + TabLayoutMediator(activeTabLayout, viewPager, TabConfigurationStrategy { _: TabLayout.Tab?, _: Int -> }).attach() + activeTabLayout.removeAllTabs() + for (i in tabs.indices) { + val tab = activeTabLayout.newTab() + .setIcon(tabs[i].icon) + if (tabs[i].id == LIST) { + tab.contentDescription = tabs[i].arguments[1] + } else { + tab.setContentDescription(tabs[i].text) + } + activeTabLayout.addTab(tab) + + if (tabs[i].id == NOTIFICATIONS) { + notificationTabPosition = i + if (selectNotificationTab) { + tab.select() + } + } + } + + val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) + viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) + + val enableSwipeForTabs = preferences.getBoolean("enableSwipeForTabs", true) + viewPager.isUserInputEnabled = enableSwipeForTabs + + onTabSelectedListener?.let { + activeTabLayout.removeOnTabSelectedListener(it) + } + + onTabSelectedListener = object : OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + if (tab.position == notificationTabPosition) { + NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager) + } + + mainToolbar.title = tabs[tab.position].title(this@MainActivity) + } + + override fun onTabUnselected(tab: TabLayout.Tab) {} + + override fun onTabReselected(tab: TabLayout.Tab) { + val fragment = adapter.getFragment(tab.position) + if (fragment is ReselectableFragment) { + (fragment as ReselectableFragment).onReselect() + } + } + }.also { + activeTabLayout.addOnTabSelectedListener(it) + } + + val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 + mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity) + mainToolbar.setOnClickListener { + (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() + } + + } + + private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { + val activeAccount = accountManager.activeAccount + + //open profile when active image was clicked + if (current && activeAccount != null) { + val intent = AccountActivity.getIntent(this, activeAccount.accountId) + startActivityWithSlideInAnimation(intent) + return false + } + //open LoginActivity to add new account + if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { + startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true)) + return false + } + //change Account + changeAccount(profile.identifier, null) + return false + } + + private fun changeAccount(newSelectedId: Long, forward: Intent?) { + cacheUpdater.stop() + SFragment.flushFilters() + accountManager.setActiveAccount(newSelectedId) + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + if (forward != null) { + intent.type = forward.type + intent.action = forward.action + intent.putExtras(forward) + } + startActivity(intent) + finishWithoutSlideOutAnimation() + overridePendingTransition(R.anim.explode, R.anim.explode) + } + + private fun logout() { + accountManager.activeAccount?.let { activeAccount -> + AlertDialog.Builder(this) + .setTitle(R.string.action_logout) + .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) + .setPositiveButton(android.R.string.yes) { _: DialogInterface?, _: Int -> + NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this) + cacheUpdater.clearForUser(activeAccount.id) + conversationRepository.deleteCacheForAccount(activeAccount.id) + removeShortcut(this, activeAccount) + val newAccount = accountManager.logActiveAccountOut() + initPullNotifications() + val intent = if (newAccount == null) { + LoginActivity.getIntent(this, false) + } else { + Intent(this, MainActivity::class.java) + } + startActivity(intent) + finishWithoutSlideOutAnimation() + } + .setNegativeButton(android.R.string.no, null) + .show() + } + } + + private fun fetchUserInfo() { + mastodonApi.accountVerifyCredentials() + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( + { userInfo -> + onFetchUserInfoSuccess(userInfo) + }, + { throwable -> + Log.e(TAG, "Failed to fetch user info. " + throwable.message) + } + ) + } + + private fun onFetchUserInfoSuccess(me: Account) { + glide.asBitmap() + .load(me.header) + .into(header.accountHeaderBackground) + + loadDrawerAvatar(me.avatar, false) + + accountManager.updateActiveAccount(me) + NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) + + initPullNotifications() + + // Show follow requests in the menu, if this is a locked account. + if (me.locked && mainDrawer.getDrawerItem(DRAWER_ITEM_FOLLOW_REQUESTS) == null) { + val followRequestsItem = primaryDrawerItem { + identifier = DRAWER_ITEM_FOLLOW_REQUESTS + nameRes = R.string.action_view_follow_requests + iconicsIcon = GoogleMaterial.Icon.gmd_person_add + onClick = { + val intent = Intent(this@MainActivity, AccountListActivity::class.java) + intent.putExtra("type", AccountListActivity.Type.FOLLOW_REQUESTS) + startActivityWithSlideInAnimation(intent) + } + } + mainDrawer.addItemAtPosition(4, followRequestsItem) + } else if (!me.locked) { + mainDrawer.removeItems(DRAWER_ITEM_FOLLOW_REQUESTS) + } + updateProfiles() + updateShortcut(this, accountManager.activeAccount!!) + } + + private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) + + glide.asDrawable() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) { + placeholder(R.drawable.avatar_default) + } + } + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + if(placeholder != null) { + mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + override fun onResourceReady(resource: Drawable, transition: Transition?) { + mainToolbar.navigationIcon = resource + } + + override fun onLoadCleared(placeholder: Drawable?) { + mainToolbar.navigationIcon = placeholder + } + }) + } + + private fun fetchAnnouncements() { + mastodonApi.listAnnouncements(false) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( + { announcements -> + unreadAnnouncementsCount = announcements.count { !it.read } + updateAnnouncementsBadge() + }, + { + Log.w(TAG, "Failed to fetch announcements.", it) + } + ) + } + + private fun updateAnnouncementsBadge() { + mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString())) + } + + private fun updateProfiles() { + val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> + val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, true)) + + ProfileDrawerItem().apply { + isSelected = acc.isActive + nameText = emojifiedName + iconUrl = acc.profilePictureUrl + isNameShown = true + identifier = acc.id + descriptionText = acc.fullName + } + }.toMutableList() + + // reuse the already existing "add account" item + for (profile in header.profiles.orEmpty()) { + if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { + profiles.add(profile) + break + } + } + header.clear() + header.profiles = profiles + header.setActiveProfile(accountManager.activeAccount!!.id) + } + + private fun draftWarning() { + val sharedPrefsKey = "show_draft_warning" + appDb.tootDao().savedTootCount() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { draftCount -> + val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true) + if (draftCount > 0 && showDraftWarning) { + AlertDialog.Builder(this) + .setMessage(R.string.new_drafts_warning) + .setNegativeButton("Don't show again") { _, _ -> + preferences.edit(commit = true) { + putBoolean(sharedPrefsKey, false) + } + } + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + } + + override fun getActionButton(): FloatingActionButton? = composeButton + + override fun androidInjector() = androidInjector + + companion object { + private const val TAG = "MainActivity" // logging tag + private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 + private const val DRAWER_ITEM_FOLLOW_REQUESTS: Long = 10 + private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 + const val STATUS_URL = "statusUrl" + } +} + +private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem { + return PrimaryDrawerItem() + .apply { + isSelectable = false + isIconTinted = true + } + .apply(block) +} + +private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem { + return SecondaryDrawerItem() + .apply { + isSelectable = false + isIconTinted = true + } + .apply(block) +} + +private var AbstractDrawerItem<*, *>.onClick: () -> Unit + get() = throw UnsupportedOperationException() + set(value) { + onDrawerItemClickListener = { _, _, _ -> + value() + false + } + } diff --git a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt new file mode 100644 index 0000000..e4655a5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt @@ -0,0 +1,69 @@ +package com.keylesspalace.tusky + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { + + companion object { + private const val ARG_KIND = "kind" + private const val ARG_ARG = "arg" + + @JvmStatic + fun newIntent(context: Context, kind: TimelineFragment.Kind, + argument: String?): Intent { + val intent = Intent(context, ModalTimelineActivity::class.java) + intent.putExtra(ARG_KIND, kind) + intent.putExtra(ARG_ARG, argument) + return intent + } + + } + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_modal_timeline) + + setSupportActionBar(toolbar) + val bar = supportActionBar + if (bar != null) { + bar.title = getString(R.string.title_list_timeline) + bar.setDisplayHomeAsUpEnabled(true) + bar.setDisplayShowHomeEnabled(true) + } + + if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) { + val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind + ?: TimelineFragment.Kind.HOME + val argument = intent?.getStringExtra(ARG_ARG) + supportFragmentManager.beginTransaction() + .replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument)) + .commit() + } + } + + override fun getActionButton(): FloatingActionButton? = null + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + return false + } + + override fun androidInjector() = dispatchingAndroidInjector + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java new file mode 100644 index 0000000..8e6b580 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -0,0 +1,222 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky; + +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.Lifecycle; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.keylesspalace.tusky.adapter.SavedTootAdapter; +import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.StatusComposedEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; +import com.keylesspalace.tusky.db.AppDatabase; +import com.keylesspalace.tusky.db.TootDao; +import com.keylesspalace.tusky.db.TootEntity; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.util.SaveTootHelper; +import com.keylesspalace.tusky.view.BackgroundMessageView; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + +import io.reactivex.android.schedulers.AndroidSchedulers; + +import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; +import static com.uber.autodispose.AutoDispose.autoDisposable; +import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; + +public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction, + Injectable { + + // ui + private SavedTootAdapter adapter; + private BackgroundMessageView errorMessageView; + + private List toots = new ArrayList<>(); + @Nullable + private AsyncTask asyncTask; + + @Inject + EventHub eventHub; + @Inject + AppDatabase database; + @Inject + SaveTootHelper saveTootHelper; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + eventHub.getEvents() + .observeOn(AndroidSchedulers.mainThread()) + .ofType(StatusComposedEvent.class) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe((__) -> this.fetchToots()); + + setContentView(R.layout.activity_saved_toot); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setTitle(getString(R.string.title_drafts)); + bar.setDisplayHomeAsUpEnabled(true); + bar.setDisplayShowHomeEnabled(true); + } + + RecyclerView recyclerView = findViewById(R.id.recyclerView); + errorMessageView = findViewById(R.id.errorMessageView); + recyclerView.setHasFixedSize(true); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + recyclerView.setLayoutManager(layoutManager); + DividerItemDecoration divider = new DividerItemDecoration( + this, layoutManager.getOrientation()); + recyclerView.addItemDecoration(divider); + adapter = new SavedTootAdapter(this); + recyclerView.setAdapter(adapter); + } + + @Override + protected void onResume() { + super.onResume(); + fetchToots(); + } + + @Override + protected void onPause() { + super.onPause(); + if (asyncTask != null) asyncTask.cancel(true); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + onBackPressed(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + private void fetchToots() { + asyncTask = new FetchPojosTask(this, database.tootDao()) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void setNoContent(int size) { + if (size == 0) { + errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status, null); + errorMessageView.setVisibility(View.VISIBLE); + } else { + errorMessageView.setVisibility(View.GONE); + } + } + + @Override + public void delete(int position, TootEntity item) { + + saveTootHelper.deleteDraft(item); + + toots.remove(position); + // update adapter + if (adapter != null) { + adapter.removeItem(position); + setNoContent(toots.size()); + } + } + + @Override + public void click(int position, TootEntity item) { + Gson gson = new Gson(); + Type stringListType = new TypeToken>() {}.getType(); + List jsonUrls = gson.fromJson(item.getUrls(), stringListType); + List descriptions = gson.fromJson(item.getDescriptions(), stringListType); + + ComposeOptions composeOptions = new ComposeOptions( + /*scheduledTootUid*/null, + item.getUid(), + /*drafId*/null, + item.getText(), + jsonUrls, + descriptions, + /*mentionedUsernames*/null, + item.getInReplyToId(), + /*replyVisibility*/null, + item.getVisibility(), + item.getContentWarning(), + item.getInReplyToUsername(), + item.getInReplyToText(), + /*mediaAttachments*/null, + /*draftAttachments*/null, + /*scheduledAt*/null, + /*sensitive*/null, + /*poll*/null, + item.getFormattingSyntax(), + /* modifiedInitialState */ true + ); + Intent intent = ComposeActivity.startIntent(this, composeOptions); + startActivity(intent); + } + + static final class FetchPojosTask extends AsyncTask> { + + private final WeakReference activityRef; + private final TootDao tootDao; + + FetchPojosTask(SavedTootActivity activity, TootDao tootDao) { + this.activityRef = new WeakReference<>(activity); + this.tootDao = tootDao; + } + + @Override + protected List doInBackground(Void... voids) { + return tootDao.loadAll(); + } + + @Override + protected void onPostExecute(List pojos) { + super.onPostExecute(pojos); + SavedTootActivity activity = activityRef.get(); + if (activity == null) return; + + activity.toots.clear(); + activity.toots.addAll(pojos); + + // set ui + activity.setNoContent(pojos.size()); + activity.adapter.setItems(activity.toots); + activity.adapter.notifyDataSetChanged(); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt new file mode 100644 index 0000000..07c54e9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt @@ -0,0 +1,50 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable + +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import javax.inject.Inject + +class SplashActivity : AppCompatActivity(), Injectable { + + @Inject + lateinit var accountManager: AccountManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + /** delete old notification channels */ + NotificationHelper.deleteLegacyNotificationChannels(this, accountManager) + + /** Determine whether the user is currently logged in, and if so go ahead and load the + * timeline. Otherwise, start the activity_login screen. */ + + val intent = if (accountManager.activeAccount != null) { + Intent(this, MainActivity::class.java) + } else { + LoginActivity.getIntent(this, false) + } + startActivity(intent) + finish() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt new file mode 100644 index 0000000..56ea4d2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -0,0 +1,96 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.fragment.app.commit + +import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.fragment.TimelineFragment.Kind + +import javax.inject.Inject + +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import kotlinx.android.extensions.CacheImplementation +import kotlinx.android.extensions.ContainerOptions +import kotlinx.android.synthetic.main.toolbar_basic.* + +class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + + private val kind: Kind + get() = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!) + + @ContainerOptions(cache = CacheImplementation.NO_CACHE) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_statuslist) + + setSupportActionBar(toolbar) + + val title = if(kind == Kind.FAVOURITES) { + R.string.title_favourites + } else { + R.string.title_bookmarks + } + + supportActionBar?.run { + setTitle(title) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + supportFragmentManager.commit { + val fragment = TimelineFragment.newInstance(kind) + replace(R.id.fragment_container, fragment) + } + + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home){ + onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun androidInjector() = dispatchingAndroidInjector + + companion object { + + private const val EXTRA_KIND = "kind" + + @JvmStatic + fun newFavouritesIntent(context: Context) = + Intent(context, StatusListActivity::class.java).apply { + putExtra(EXTRA_KIND, Kind.FAVOURITES.name) + } + + @JvmStatic + fun newBookmarksIntent(context: Context) = + Intent(context, StatusListActivity::class.java).apply { + putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt new file mode 100644 index 0000000..cf74670 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -0,0 +1,112 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.fragment.ChatsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment +import com.keylesspalace.tusky.fragment.TimelineFragment + +/** this would be a good case for a sealed class, but that does not work nice with Room */ + +const val HOME = "Home" +const val NOTIFICATIONS = "Notifications" +const val LOCAL = "Local" +const val FEDERATED = "Federated" +const val DIRECT = "Direct" +const val HASHTAG = "Hashtag" +const val LIST = "List" +const val CHATS = "Chats" + +data class TabData(val id: String, + @StringRes val text: Int, + @DrawableRes val icon: Int, + val fragment: (List) -> Fragment, + val arguments: List = emptyList(), + val title: (Context) -> String = { context -> context.getString(text)} + ) + +fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { + return when (id) { + HOME -> TabData( + HOME, + R.string.title_home, + R.drawable.ic_home_24dp, + { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } + ) + NOTIFICATIONS -> TabData( + NOTIFICATIONS, + R.string.title_notifications, + R.drawable.ic_notifications_24dp, + { NotificationsFragment.newInstance() } + ) + LOCAL -> TabData( + LOCAL, + R.string.title_public_local, + R.drawable.ic_local_24dp, + { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) } + ) + FEDERATED -> TabData( + FEDERATED, + R.string.title_public_federated, + R.drawable.ic_public_24dp, + { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } + ) + DIRECT -> TabData( + DIRECT, + R.string.title_direct_messages, + R.drawable.ic_reblog_direct_24dp, + { ConversationsFragment.newInstance() } + ) + HASHTAG -> TabData( + HASHTAG, + R.string.hashtags, + R.drawable.ic_hashtag, + { args -> TimelineFragment.newHashtagInstance(args) }, + arguments, + { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) }} + ) + LIST -> TabData( + LIST, + R.string.list, + R.drawable.ic_list, + { args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty()) }, + arguments, + { arguments.getOrNull(1).orEmpty() } + ) + CHATS -> TabData( + CHATS, + R.string.chats, + R.drawable.ic_forum_24px, + { ChatsFragment() } + ) + else -> throw IllegalArgumentException("unknown tab type") + } +} + +fun defaultTabs(): List { + return listOf( + createTabDataFromId(HOME), + createTabDataFromId(NOTIFICATIONS), + createTabDataFromId(LOCAL), + createTabDataFromId(FEDERATED), + createTabDataFromId(CHATS) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt new file mode 100644 index 0000000..b5539bb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -0,0 +1,372 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.widget.FrameLayout +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.TransitionManager +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.transition.MaterialArcMotion +import com.google.android.material.transition.MaterialContainerTransform +import com.keylesspalace.tusky.adapter.ItemInteractionListener +import com.keylesspalace.tusky.adapter.ListSelectionAdapter +import com.keylesspalace.tusky.adapter.TabAdapter +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MainTabsChangedEvent +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.visible +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.activity_tab_preference.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import java.util.regex.Pattern +import javax.inject.Inject + +class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener { + + @Inject + lateinit var mastodonApi: MastodonApi + @Inject + lateinit var eventHub: EventHub + + private lateinit var currentTabs: MutableList + private lateinit var currentTabsAdapter: TabAdapter + private lateinit var touchHelper: ItemTouchHelper + private lateinit var addTabAdapter: TabAdapter + + private var tabsChanged = false + + private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } + + private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_tab_preference) + + setSupportActionBar(toolbar) + + supportActionBar?.apply { + setTitle(R.string.title_tab_preferences) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList() + currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT) + currentTabsRecyclerView.adapter = currentTabsAdapter + currentTabsRecyclerView.layoutManager = LinearLayoutManager(this) + currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) + + addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this) + addTabRecyclerView.adapter = addTabAdapter + addTabRecyclerView.layoutManager = LinearLayoutManager(this) + + touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() { + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END) + } + + override fun isLongPressDragEnabled(): Boolean { + return true + } + + override fun isItemViewSwipeEnabled(): Boolean { + return MIN_TAB_COUNT < currentTabs.size + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val temp = currentTabs[viewHolder.adapterPosition] + currentTabs[viewHolder.adapterPosition] = currentTabs[target.adapterPosition] + currentTabs[target.adapterPosition] = temp + + currentTabsAdapter.notifyItemMoved(viewHolder.adapterPosition, target.adapterPosition) + saveTabs() + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + onTabRemoved(viewHolder.adapterPosition) + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder?.itemView?.elevation = selectedItemElevation + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.elevation = 0f + } + }) + + touchHelper.attachToRecyclerView(currentTabsRecyclerView) + + actionButton.setOnClickListener { + toggleFab(true) + } + + scrim.setOnClickListener { + toggleFab(false) + } + + maxTabsInfo.text = getString(R.string.max_tab_number_reached, MAX_TAB_COUNT) + + updateAvailableTabs() + } + + override fun onTabAdded(tab: TabData) { + + if (currentTabs.size >= MAX_TAB_COUNT) { + return + } + + toggleFab(false) + + if (tab.id == HASHTAG) { + showAddHashtagDialog() + return + } + + if (tab.id == LIST) { + showSelectListDialog() + return + } + + currentTabs.add(tab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + updateAvailableTabs() + saveTabs() + } + + override fun onTabRemoved(position: Int) { + currentTabs.removeAt(position) + currentTabsAdapter.notifyItemRemoved(position) + updateAvailableTabs() + saveTabs() + } + + override fun onActionChipClicked(tab: TabData, tabPosition: Int) { + showAddHashtagDialog(tab, tabPosition) + } + + override fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) { + val newArguments = tab.arguments.filterIndexed { i, _ -> i != chipPosition } + val newTab = tab.copy(arguments = newArguments) + currentTabs[tabPosition] = newTab + saveTabs() + + currentTabsAdapter.notifyItemChanged(tabPosition) + } + + private fun toggleFab(expand: Boolean) { + val transition = MaterialContainerTransform().apply { + startView = if (expand) actionButton else sheet + val endView: View = if (expand) sheet else actionButton + this.endView = endView + addTarget(endView) + scrimColor = Color.TRANSPARENT + setPathMotion(MaterialArcMotion()) + } + + TransitionManager.beginDelayedTransition(tabPreferenceContainer, transition) + actionButton.visible(!expand) + sheet.visible(expand) + scrim.visible(expand) + } + + private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) { + + val frameLayout = FrameLayout(this) + val padding = Utils.dpToPx(this, 8) + frameLayout.updatePadding(left = padding, right = padding) + + val editText = AppCompatEditText(this) + editText.setHint(R.string.edit_hashtag_hint) + editText.setText("") + frameLayout.addView(editText) + + val dialog = AlertDialog.Builder(this) + .setTitle(R.string.add_hashtag_title) + .setView(frameLayout) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.action_save) { _, _ -> + val input = editText.text.toString().trim() + if (tab == null) { + val newTab = createTabDataFromId(HASHTAG, listOf(input)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + } else { + val newTab = tab.copy(arguments = tab.arguments + input) + currentTabs[tabPosition] = newTab + + currentTabsAdapter.notifyItemChanged(tabPosition) + } + + updateAvailableTabs() + saveTabs() + } + .create() + + editText.onTextChanged { s, _, _, _ -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) + } + + dialog.show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(editText.text) + editText.requestFocus() + } + + private fun showSelectListDialog() { + val adapter = ListSelectionAdapter(this) + mastodonApi.getLists() + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe ( + { lists -> + adapter.addAll(lists) + }, + { throwable -> + Log.e("TabPreferenceActivity", "failed to load lists", throwable) + } + ) + + AlertDialog.Builder(this) + .setTitle(R.string.select_list_title) + .setAdapter(adapter) { _, position -> + val list = adapter.getItem(position) + val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + updateAvailableTabs() + saveTabs() + } + .show() + } + + private fun validateHashtag(input: CharSequence?): Boolean { + val trimmedInput = input?.trim() ?: "" + return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches() + } + + private fun updateAvailableTabs() { + val addableTabs: MutableList = mutableListOf() + + val homeTab = createTabDataFromId(HOME) + if (!currentTabs.contains(homeTab)) { + addableTabs.add(homeTab) + } + val notificationTab = createTabDataFromId(NOTIFICATIONS) + if (!currentTabs.contains(notificationTab)) { + addableTabs.add(notificationTab) + } + val localTab = createTabDataFromId(LOCAL) + if (!currentTabs.contains(localTab)) { + addableTabs.add(localTab) + } + val federatedTab = createTabDataFromId(FEDERATED) + if (!currentTabs.contains(federatedTab)) { + addableTabs.add(federatedTab) + } + val directMessagesTab = createTabDataFromId(DIRECT) + if (!currentTabs.contains(directMessagesTab)) { + addableTabs.add(directMessagesTab) + } + val chatTab = createTabDataFromId(CHATS) + if (!currentTabs.contains(chatTab)) { + addableTabs.add(chatTab) + } + + addableTabs.add(createTabDataFromId(HASHTAG)) + addableTabs.add(createTabDataFromId(LIST)) + + addTabAdapter.updateData(addableTabs) + + maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) + currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT); + } + + override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) { + touchHelper.startSwipe(viewHolder) + } + + override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) { + touchHelper.startDrag(viewHolder) + } + + private fun saveTabs() { + accountManager.activeAccount?.let { + Single.fromCallable { + it.tabPreferences = currentTabs + accountManager.saveAccount(it) + } + .subscribeOn(Schedulers.io()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe() + + } + tabsChanged = true + } + + override fun onBackPressed() { + if (actionButton.isVisible) { + super.onBackPressed() + } else { + toggleFab(false) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + return false + } + + override fun onPause() { + super.onPause() + if (tabsChanged) { + eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + } + } + + companion object { + private const val MIN_TAB_COUNT = 2 + private const val MAX_TAB_COUNT = 9 + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt new file mode 100644 index 0000000..fe5f3b8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -0,0 +1,114 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.app.Application +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.util.Log +import androidx.emoji.text.EmojiCompat +import androidx.preference.PreferenceManager +import androidx.work.WorkManager +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.github.piasy.biv.BigImageViewer +import com.github.piasy.biv.loader.glide.GlideCustomImageLoader +import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory +import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.EmojiCompatFont +import com.keylesspalace.tusky.util.LocaleManager +import com.keylesspalace.tusky.util.ThemeUtils +import com.uber.autodispose.AutoDisposePlugins +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import io.reactivex.plugins.RxJavaPlugins +import org.conscrypt.Conscrypt +import java.security.Security +import javax.inject.Inject + +class TuskyApplication : Application(), HasAndroidInjector { + + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + @Inject + lateinit var notificationWorkerFactory: NotificationWorkerFactory + + override fun onCreate() { + // Uncomment me to get StrictMode violation logs +// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { +// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() +// .detectDiskReads() +// .detectDiskWrites() +// .detectNetwork() +// .detectUnbufferedIo() +// .penaltyLog() +// .build()) +// } + super.onCreate() + + Security.insertProviderAt(Conscrypt.newProvider(), 1) + + AutoDisposePlugins.setHideProxies(false) // a small performance optimization + + AppInjector.init(this) + + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + + // init the custom emoji fonts + val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) + val emojiConfig = EmojiCompatFont.byId(emojiSelection) + .getConfig(this) + .setReplaceAll(true) + EmojiCompat.init(emojiConfig) + + // init night mode + val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + ThemeUtils.setAppNightMode(theme) + + RxJavaPlugins.setErrorHandler { + Log.w("RxJava", "undeliverable exception", it) + } + + SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888) + BigImageViewer.initialize(GlideCustomImageLoader.with(this)) + + WorkManager.initialize( + this, + androidx.work.Configuration.Builder() + .setWorkerFactory(notificationWorkerFactory) + .build() + ) + } + + override fun attachBaseContext(base: Context) { + localeManager = LocaleManager(base) + super.attachBaseContext(localeManager.setLocale(base)) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + localeManager.setLocale(this) + } + + override fun androidInjector() = androidInjector + + companion object { + @JvmStatic + lateinit var localeManager: LocaleManager + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt new file mode 100644 index 0000000..b40adb1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -0,0 +1,388 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.Manifest +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.app.DownloadManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.transition.Transition +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.bumptech.glide.Glide +import com.bumptech.glide.request.FutureTarget +import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.fragment.ViewImageFragment +import com.keylesspalace.tusky.pager.AvatarImagePagerAdapter +import com.keylesspalace.tusky.pager.ImagePagerAdapter +import com.keylesspalace.tusky.util.getTemporaryMediaFilename +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider +import com.uber.autodispose.autoDispose +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.activity_view_media.* +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.util.* + +typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit + +class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener { + companion object { + private const val EXTRA_ATTACHMENTS = "attachments" + private const val EXTRA_ATTACHMENT_INDEX = "index" + private const val EXTRA_AVATAR_URL = "avatar" + private const val TAG = "ViewMediaActivity" + + @JvmStatic + fun newIntent(context: Context?, attachments: List, index: Int): Intent { + val intent = Intent(context, ViewMediaActivity::class.java) + intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, ArrayList(attachments)) + intent.putExtra(EXTRA_ATTACHMENT_INDEX, index) + return intent + } + + @JvmStatic + fun newIntent(context: Context?, attachment: Attachment): Intent { + val intent = Intent(context, ViewMediaActivity::class.java) + intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, + arrayListOf(AttachmentViewData(attachment, null, null))) + intent.putExtra(EXTRA_ATTACHMENT_INDEX, 0) + return intent + } + + fun newSingleImageIntent(context: Context?, url: String): Intent { + val intent = Intent(context, ViewMediaActivity::class.java) + intent.putExtra(EXTRA_AVATAR_URL, url) + return intent + } + } + + var isToolbarVisible = true + private set + + private var attachments: ArrayList? = null + private val toolbarVisibilityListeners = mutableListOf() + + fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0 { + this.toolbarVisibilityListeners.add(listener) + listener(isToolbarVisible) + return { toolbarVisibilityListeners.remove(listener) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_view_media) + + supportPostponeEnterTransition() + + // Gather the parameters. + attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS) + val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) + + // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener + // but it cannot be expressed and if I don't specify type explicitly compilation fails + // (probably a bug in compiler) + val adapter: ViewMediaAdapter = if (attachments != null) { + val realAttachs = attachments!!.map(AttachmentViewData::attachment) + // Setup the view pager. + ImagePagerAdapter(this, realAttachs, initialPosition) + + } else { + val avatarUrl = intent.getStringExtra(EXTRA_AVATAR_URL) + ?: throw IllegalArgumentException("attachment list or avatar url has to be set") + + AvatarImagePagerAdapter(this, avatarUrl) + } + + viewPager.adapter = adapter + viewPager.setCurrentItem(initialPosition, false) + viewPager.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + toolbar.title = getPageTitle(position) + } + }) + + // Setup the toolbar. + setSupportActionBar(toolbar) + val actionBar = supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setDisplayShowHomeEnabled(true) + actionBar.title = getPageTitle(initialPosition) + } + toolbar.setNavigationOnClickListener { supportFinishAfterTransition() } + toolbar.setOnMenuItemClickListener { item: MenuItem -> + when (item.itemId) { + R.id.action_open_in_external_app -> openInExternalApp() + R.id.action_download -> requestDownloadMedia() + R.id.action_open_status -> onOpenStatus() + R.id.action_share_media -> shareMedia() + R.id.action_copy_media_link -> copyLink() + } + true + } + + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE + window.statusBarColor = Color.BLACK + window.sharedElementEnterTransition.addListener(object : NoopTransitionListener { + override fun onTransitionEnd(transition: Transition) { + adapter.onTransitionEnd(viewPager.currentItem) + window.sharedElementEnterTransition.removeListener(this) + } + }) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + if (attachments != null) { + menuInflater.inflate(R.menu.view_media_toolbar, menu) + return true + } + return false + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + menu?.findItem(R.id.action_share_media)?.isEnabled = !isCreating + + if(attachments != null) { + val isStatus = attachments!!.any { it.statusId != null && it.statusUrl != null } + menu?.findItem(R.id.action_open_status)?.isVisible = isStatus + } + return true + } + + override fun onBringUp() { + supportStartPostponedEnterTransition() + } + + override fun onDismiss() { + supportFinishAfterTransition() + } + + override fun onPhotoTap() { + isToolbarVisible = !isToolbarVisible + for (listener in toolbarVisibilityListeners) { + listener(isToolbarVisible) + } + + val visibility = if (isToolbarVisible) View.VISIBLE else View.INVISIBLE + val alpha = if (isToolbarVisible) 1.0f else 0.0f + if (isToolbarVisible) { + // If to be visible, need to make visible immediately and animate alpha + toolbar.alpha = 0.0f + toolbar.visibility = visibility + } + + toolbar.animate().alpha(alpha) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + toolbar.visibility = visibility + animation.removeListener(this) + } + }) + .start() + } + + private fun getPageTitle(position: Int): CharSequence { + if(attachments == null) { + return "" + } + return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size) + } + + private fun downloadMedia() { + val url = attachments!![viewPager.currentItem].attachment.url + val filename = Uri.parse(url).lastPathSegment + Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show() + + val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(Uri.parse(url)) + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, + getString(R.string.app_name) + "/" + filename) + downloadManager.enqueue(request) + } + + private fun requestDownloadMedia() { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadMedia() + } else { + showErrorDialog(toolbar, R.string.error_media_download_permission, R.string.action_retry) { requestDownloadMedia() } + } + } + } + + private fun onOpenStatus() { + val attach = attachments!![viewPager.currentItem] + startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl)) + } + + private fun copyLink() { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(null, attachments!![viewPager.currentItem].attachment.url)) + } + + private fun shareMedia() { + val directory = applicationContext.getExternalFilesDir("Husky") + if (directory == null || !(directory.exists())) { + Log.e(TAG, "Error obtaining directory to save temporary media.") + return + } + + val attachment = attachments!![viewPager.currentItem].attachment + when (attachment.type) { + Attachment.Type.IMAGE -> shareImage(directory, attachment.url) + Attachment.Type.AUDIO, + Attachment.Type.VIDEO, + Attachment.Type.GIFV -> shareMediaFile(directory, attachment.url) + else -> Log.e(TAG, "Unknown media format for sharing.") + } + } + + private fun shareFile(file: File, mimeType: String?) { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)) + sendIntent.type = mimeType + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to))) + } + + private fun openInExternalApp() { + val url = attachments!![viewPager.currentItem].attachment.url + val intent = Intent(Intent.ACTION_VIEW) + val extension = MimeTypeMap.getFileExtensionFromUrl(url) + if(extension != null) { + intent.setDataAndType(Uri.parse(url), MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)) + } else { + intent.data = Uri.parse(url) + } + + startActivity(intent) + } + + + private var isCreating: Boolean = false + + private fun shareImage(directory: File, url: String) { + isCreating = true + progressBarShare.visibility = View.VISIBLE + invalidateOptionsMenu() + val file = File(directory, getTemporaryMediaFilename("png")) + val futureTask: FutureTarget = + Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit() + Single.fromCallable { + val bitmap = futureTask.get() + try { + val stream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.close() + return@fromCallable true + } catch (fnfe: FileNotFoundException) { + Log.e(TAG, "Error writing temporary media.") + } catch (ioe: IOException) { + Log.e(TAG, "Error writing temporary media.") + } + return@fromCallable false + + } + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnDispose { + futureTask.cancel(true) + } + .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { result -> + Log.d(TAG, "Download image result: $result") + isCreating = false + invalidateOptionsMenu() + progressBarShare.visibility = View.GONE + if (result) + shareFile(file, "image/png") + }, + { error -> + isCreating = false + invalidateOptionsMenu() + progressBarShare.visibility = View.GONE + Log.e(TAG, "Failed to download image", error) + } + ) + + } + + private fun shareMediaFile(directory: File, url: String) { + val uri = Uri.parse(url) + val mimeTypeMap = MimeTypeMap.getSingleton() + val extension = MimeTypeMap.getFileExtensionFromUrl(url) + val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension) + val filename = getTemporaryMediaFilename(extension) + val file = File(directory, filename) + + val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(uri) + request.setDestinationUri(Uri.fromFile(file)) + request.setVisibleInDownloadsUi(false) + downloadManager.enqueue(request) + + shareFile(file, mimeType) + } +} + +abstract class ViewMediaAdapter(activity: FragmentActivity): FragmentStateAdapter(activity) { + abstract fun onTransitionEnd(position: Int) +} + +interface NoopTransitionListener : Transition.TransitionListener { + override fun onTransitionEnd(transition: Transition) { + } + + override fun onTransitionResume(transition: Transition) { + } + + override fun onTransitionPause(transition: Transition) { + } + + override fun onTransitionCancel(transition: Transition) { + } + + override fun onTransitionStart(transition: Transition) { + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java new file mode 100644 index 0000000..a49dcc8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java @@ -0,0 +1,91 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; + +import com.keylesspalace.tusky.fragment.TimelineFragment; + +import java.util.Collections; + +import javax.inject.Inject; + +import dagger.android.AndroidInjector; +import dagger.android.DispatchingAndroidInjector; +import dagger.android.HasAndroidInjector; + +public class ViewTagActivity extends BottomSheetActivity implements HasAndroidInjector { + + private static final String HASHTAG = "hashtag"; + + @Inject + public DispatchingAndroidInjector dispatchingAndroidInjector; + + public static Intent getIntent(Context context, String tag){ + Intent intent = new Intent(context,ViewTagActivity.class); + intent.putExtra(HASHTAG,tag); + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_view_tag); + + String hashtag = getIntent().getStringExtra(HASHTAG); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar bar = getSupportActionBar(); + + if (bar != null) { + bar.setTitle(String.format(getString(R.string.title_tag), hashtag)); + bar.setDisplayHomeAsUpEnabled(true); + bar.setDisplayShowHomeEnabled(true); + } + + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + Fragment fragment = TimelineFragment.newHashtagInstance(Collections.singletonList(hashtag)); + fragmentTransaction.replace(R.id.fragment_container, fragment); + fragmentTransaction.commit(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + onBackPressed(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + @Override + public AndroidInjector androidInjector() { + return dispatchingAndroidInjector; + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java new file mode 100644 index 0000000..2267dc5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java @@ -0,0 +1,130 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentTransaction; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import android.view.Menu; +import android.view.MenuItem; + +import com.keylesspalace.tusky.fragment.ViewThreadFragment; +import com.keylesspalace.tusky.util.LinkHelper; + +import javax.inject.Inject; + +import dagger.android.AndroidInjector; +import dagger.android.DispatchingAndroidInjector; +import dagger.android.HasAndroidInjector; + +public class ViewThreadActivity extends BottomSheetActivity implements HasAndroidInjector { + + public static final int REVEAL_BUTTON_HIDDEN = 1; + public static final int REVEAL_BUTTON_REVEAL = 2; + public static final int REVEAL_BUTTON_HIDE = 3; + + public static Intent startIntent(Context context, String id, String url) { + Intent intent = new Intent(context, ViewThreadActivity.class); + intent.putExtra(ID_EXTRA, id); + intent.putExtra(URL_EXTRA, url); + return intent; + } + + private static final String ID_EXTRA = "id"; + private static final String URL_EXTRA = "url"; + private static final String FRAGMENT_TAG = "ViewThreadFragment_"; + + private int revealButtonState = REVEAL_BUTTON_HIDDEN; + + @Inject + public DispatchingAndroidInjector dispatchingAndroidInjector; + + private ViewThreadFragment fragment; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_view_thread); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.title_view_thread); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + + String id = getIntent().getStringExtra(ID_EXTRA); + + fragment = (ViewThreadFragment)getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG + id); + if(fragment == null) { + fragment = ViewThreadFragment.newInstance(id); + } + + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id); + fragmentTransaction.commit(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.view_thread_toolbar, menu); + MenuItem menuItem = menu.findItem(R.id.action_reveal); + menuItem.setVisible(revealButtonState != REVEAL_BUTTON_HIDDEN); + menuItem.setIcon(revealButtonState == REVEAL_BUTTON_REVEAL ? + R.drawable.ic_eye_24dp : R.drawable.ic_hide_media_24dp); + return super.onCreateOptionsMenu(menu); + } + + public void setRevealButtonState(int state) { + switch (state) { + case REVEAL_BUTTON_HIDDEN: + case REVEAL_BUTTON_REVEAL: + case REVEAL_BUTTON_HIDE: + this.revealButtonState = state; + invalidateOptionsMenu(); + break; + default: + throw new IllegalArgumentException("Invalid reveal button state: " + state); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + onBackPressed(); + return true; + } + case R.id.action_reveal: { + fragment.onRevealPressed(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + @Override + public AndroidInjector androidInjector() { + return dispatchingAndroidInjector; + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java new file mode 100644 index 0000000..5c52e39 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java @@ -0,0 +1,112 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.util.ListUtils; + +import java.util.ArrayList; +import java.util.List; + +public abstract class AccountAdapter extends RecyclerView.Adapter { + static final int VIEW_TYPE_ACCOUNT = 0; + static final int VIEW_TYPE_FOOTER = 1; + + List accountList; + AccountActionListener accountActionListener; + private boolean bottomLoading; + + AccountAdapter(AccountActionListener accountActionListener) { + this.accountList = new ArrayList<>(); + this.accountActionListener = accountActionListener; + bottomLoading = false; + } + + @Override + public int getItemCount() { + return accountList.size() + (bottomLoading ? 1 : 0); + } + + @Override + public int getItemViewType(int position) { + if (position == accountList.size() && bottomLoading) { + return VIEW_TYPE_FOOTER; + } else { + return VIEW_TYPE_ACCOUNT; + } + } + + public void update(@NonNull List newAccounts) { + accountList = ListUtils.removeDuplicates(newAccounts); + notifyDataSetChanged(); + } + + public void addItems(@NonNull List newAccounts) { + int end = accountList.size(); + Account last = accountList.get(end - 1); + if (last != null && !findAccount(newAccounts, last.getId())) { + accountList.addAll(newAccounts); + notifyItemRangeInserted(end, newAccounts.size()); + } + } + + public void setBottomLoading(boolean loading) { + boolean wasLoading = bottomLoading; + if(wasLoading == loading) { + return; + } + bottomLoading = loading; + if(loading) { + notifyItemInserted(accountList.size()); + } else { + notifyItemRemoved(accountList.size()); + } + } + + private static boolean findAccount(@NonNull List accounts, String id) { + for (Account account : accounts) { + if (account.getId().equals(id)) { + return true; + } + } + return false; + } + + @Nullable + public Account removeItem(int position) { + if (position < 0 || position >= accountList.size()) { + return null; + } + Account account = accountList.remove(position); + notifyItemRemoved(position); + return account; + } + + public void addItem(@NonNull Account account, int position) { + if (position < 0 || position > accountList.size()) { + return; + } + accountList.add(position, account); + notifyItemInserted(position); + } + + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt new file mode 100644 index 0000000..e80129c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -0,0 +1,78 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.text.method.LinkMovementMethod +import androidx.recyclerview.widget.RecyclerView +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.View +import android.widget.TextView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.* +import kotlinx.android.synthetic.main.item_account_field.view.* + +class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter() { + + var emojis: List = emptyList() + var fields: List> = emptyList() + + override fun getItemCount() = fields.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_account_field, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val proofOrField = fields[position] + + if(proofOrField.isLeft()) { + val identityProof = proofOrField.asLeft() + + viewHolder.nameTextView.text = identityProof.provider + viewHolder.valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl) + + viewHolder.valueTextView.movementMethod = LinkMovementMethod.getInstance() + + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + } else { + val field = proofOrField.asRight() + val emojifiedName = field.name.emojify(emojis, viewHolder.nameTextView) + viewHolder.nameTextView.text = emojifiedName + + val emojifiedValue = field.value.emojify(emojis, viewHolder.valueTextView) + LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) + + if(field.verifiedAt != null) { + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + } else { + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) + } + } + + } + + class ViewHolder(rootView: View) : RecyclerView.ViewHolder(rootView) { + val nameTextView: TextView = rootView.accountFieldName + val valueTextView: TextView = rootView.accountFieldValue + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt new file mode 100644 index 0000000..768c288 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -0,0 +1,98 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +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 com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.StringField +import kotlinx.android.synthetic.main.item_edit_field.view.* + +class AccountFieldEditAdapter : RecyclerView.Adapter() { + + private val fieldData = mutableListOf() + + fun setFields(fields: List) { + fieldData.clear() + + fields.forEach { field -> + fieldData.add(MutableStringPair(field.name, field.value)) + } + if(fieldData.isEmpty()) { + fieldData.add(MutableStringPair("", "")) + } + + notifyDataSetChanged() + } + + fun getFieldData(): List { + return fieldData.map { + StringField(it.first, it.second) + } + } + + fun addField() { + fieldData.add(MutableStringPair("", "")) + notifyItemInserted(fieldData.size - 1) + } + + override fun getItemCount(): Int = fieldData.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_edit_field, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + viewHolder.nameTextView.setText(fieldData[position].first) + viewHolder.valueTextView.setText(fieldData[position].second) + + viewHolder.nameTextView.addTextChangedListener(object: TextWatcher { + override fun afterTextChanged(newText: Editable) { + fieldData[viewHolder.adapterPosition].first = newText.toString() + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + }) + + viewHolder.valueTextView.addTextChangedListener(object: TextWatcher { + override fun afterTextChanged(newText: Editable) { + fieldData[viewHolder.adapterPosition].second = newText.toString() + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + }) + + } + + class ViewHolder(rootView: View) : RecyclerView.ViewHolder(rootView) { + val nameTextView: EditText = rootView.accountFieldName + val valueTextView: EditText = rootView.accountFieldValue + } + + class MutableStringPair (var first: String, var second: String) + + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt new file mode 100644 index 0000000..dae0db4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt @@ -0,0 +1,57 @@ +/* Copyright 2019 Levi Bard + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.util.* +import kotlinx.android.synthetic.main.item_autocomplete_account.view.* + +class AccountSelectionAdapter(context: Context) : ArrayAdapter(context, R.layout.item_autocomplete_account) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var view = convertView + + if (convertView == null) { + val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + view = layoutInflater.inflate(R.layout.item_autocomplete_account, parent, false) + } + view!! + + val account = getItem(position) + if (account != null) { + val username = view.username + val displayName = view.display_name + val avatar = view.avatar + username.text = account.fullName + displayName.text = account.displayName.emojify(account.emojis, displayName) + + val avatarRadius = avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) + val animateAvatar = PreferenceManager.getDefaultSharedPreferences(avatar.context) + .getBoolean("animateGifAvatars", false) + + loadAvatar(account.profilePictureUrl, avatar, avatarRadius, animateAvatar) + + } + + return view + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java new file mode 100644 index 0000000..3ac5968 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java @@ -0,0 +1,64 @@ +package com.keylesspalace.tusky.adapter; + +import android.content.SharedPreferences; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.LinkListener; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; + +public class AccountViewHolder extends RecyclerView.ViewHolder { + private TextView username; + private TextView displayName; + private ImageView avatar; + private ImageView avatarInset; + private String accountId; + private boolean showBotOverlay; + private boolean animateAvatar; + + public AccountViewHolder(View itemView) { + super(itemView); + username = itemView.findViewById(R.id.account_username); + displayName = itemView.findViewById(R.id.account_display_name); + avatar = itemView.findViewById(R.id.account_avatar); + avatarInset = itemView.findViewById(R.id.account_avatar_inset); + SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()); + showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); + animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false); + } + + public void setupWithAccount(Account account) { + accountId = account.getId(); + String format = username.getContext().getString(R.string.status_username_format); + String formattedUsername = String.format(format, account.getUsername()); + username.setText(formattedUsername); + CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, true); + displayName.setText(emojifiedName); + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_48dp); + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); + if (showBotOverlay && account.getBot()) { + avatarInset.setVisibility(View.VISIBLE); + avatarInset.setImageResource(R.drawable.ic_bot_24dp); + avatarInset.setBackgroundColor(0x50ffffff); + } else { + avatarInset.setVisibility(View.GONE); + } + } + + void setupActionListener(final AccountActionListener listener) { + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); + } + + public void setupLinkListener(final LinkListener listener) { + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt new file mode 100644 index 0000000..6024199 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt @@ -0,0 +1,92 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.visible + +class AddPollOptionsAdapter( + private var options: MutableList, + private val maxOptionLength: Int, + private val onOptionRemoved: (Boolean) -> Unit, + private val onOptionChanged: (Boolean) -> Unit +): RecyclerView.Adapter() { + + val pollOptions: List + get() = options.toList() + + fun addChoice() { + options.add("") + notifyItemInserted(options.size - 1) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val holder = ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_add_poll_option, parent, false)) + holder.editText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) + + holder.editText.onTextChanged { s, _, _, _ -> + val pos = holder.adapterPosition + if(pos != RecyclerView.NO_POSITION) { + options[pos] = s.toString() + onOptionChanged(validateInput()) + } + } + + return holder + } + + override fun getItemCount() = options.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.editText.setText(options[position]) + + holder.textInputLayout.hint = holder.textInputLayout.context.getString(R.string.poll_new_choice_hint, position + 1) + + holder.deleteButton.visible(position > 1, View.INVISIBLE) + + holder.deleteButton.setOnClickListener { + holder.editText.clearFocus() + options.removeAt(holder.adapterPosition) + notifyItemRemoved(holder.adapterPosition) + onOptionRemoved(validateInput()) + } + } + + private fun validateInput(): Boolean { + if (options.contains("") || options.distinct().size != options.size) { + return false + } + + return true + } + +} + + +class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + val textInputLayout: TextInputLayout = itemView.findViewById(R.id.optionTextInputLayout) + val editText: TextInputEditText = itemView.findViewById(R.id.optionEditText) + val deleteButton: ImageButton = itemView.findViewById(R.id.deleteButton) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java new file mode 100644 index 0000000..073d76d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java @@ -0,0 +1,109 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; + +public class BlocksAdapter extends AccountAdapter { + + public BlocksAdapter(AccountActionListener accountActionListener) { + super(accountActionListener); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_ACCOUNT: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_blocked_user, parent, false); + return new BlockedUserViewHolder(view); + } + case VIEW_TYPE_FOOTER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + return new LoadingFooterViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { + BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder; + holder.setupWithAccount(accountList.get(position)); + holder.setupActionListener(accountActionListener); + } + } + + static class BlockedUserViewHolder extends RecyclerView.ViewHolder { + private ImageView avatar; + private TextView username; + private TextView displayName; + private ImageButton unblock; + private String id; + private boolean animateAvatar; + + BlockedUserViewHolder(View itemView) { + super(itemView); + avatar = itemView.findViewById(R.id.blocked_user_avatar); + username = itemView.findViewById(R.id.blocked_user_username); + displayName = itemView.findViewById(R.id.blocked_user_display_name); + unblock = itemView.findViewById(R.id.blocked_user_unblock); + animateAvatar = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()) + .getBoolean("animateGifAvatars", false); + + } + + void setupWithAccount(Account account) { + id = account.getId(); + CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName); + displayName.setText(emojifiedName); + String format = username.getContext().getString(R.string.status_username_format); + String formattedUsername = String.format(format, account.getUsername()); + username.setText(formattedUsername); + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_48dp); + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); + } + + void setupActionListener(final AccountActionListener listener) { + unblock.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onBlock(false, id, position); + } + }); + itemView.setOnClickListener(v -> listener.onViewAccount(id)); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ChatMessagesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ChatMessagesAdapter.kt new file mode 100644 index 0000000..cb4f650 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ChatMessagesAdapter.kt @@ -0,0 +1,229 @@ +package com.keylesspalace.tusky.adapter + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.text.TextUtils +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.interfaces.ChatActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.TimestampUtils +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.view.MediaPreviewImageView +import com.keylesspalace.tusky.viewdata.ChatMessageViewData +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.roundToInt + +class ChatMessagesViewHolder(view: View) : RecyclerView.ViewHolder(view) { + object Key { + const val KEY_CREATED = "created" + } + + private val content: TextView = view.findViewById(R.id.content) + private val timestamp: TextView = view.findViewById(R.id.datetime) + private val attachmentView: MediaPreviewImageView = view.findViewById(R.id.attachment) + private val mediaOverlay: ImageView = view.findViewById(R.id.mediaOverlay) + private val attachmentLayout: FrameLayout = view.findViewById(R.id.attachmentLayout) + + private val sdf = SimpleDateFormat("HH:mm", Locale.getDefault()) + + private val mediaPreviewUnloaded = ColorDrawable(ThemeUtils.getColor(itemView.context, R.attr.colorBackgroundAccent)) + + fun setupWithChatMessage(msg: ChatMessageViewData.Concrete, chatActionListener: ChatActionListener, payload: Any?) { + if(payload == null) { + if(msg.content != null) { + val text = msg.content.emojify(msg.emojis, content) + LinkHelper.setClickableText(content, text, null, chatActionListener) + } + + setAttachment(msg.attachment, chatActionListener) + setCreatedAt(msg.createdAt) + } else { + if(payload is List<*>) { + for (item in payload) { + if (ChatsViewHolder.Key.KEY_CREATED == item) { + setCreatedAt(msg.createdAt) + } + } + } + } + } + + private fun loadImage(imageView: MediaPreviewImageView, + previewUrl: String?, + meta: Attachment.MetaData?) { + if (TextUtils.isEmpty(previewUrl)) { + imageView.removeFocalPoint() + Glide.with(imageView) + .load(mediaPreviewUnloaded) + .centerInside() + .into(imageView) + } else { + val focus = meta?.focus + if (focus != null) { // If there is a focal point for this attachment: + imageView.setFocalPoint(focus) + Glide.with(imageView) + .load(previewUrl) + .placeholder(mediaPreviewUnloaded) + .centerInside() + .addListener(imageView) + .into(imageView) + } else { + imageView.removeFocalPoint() + Glide.with(imageView) + .load(previewUrl) + .placeholder(mediaPreviewUnloaded) + .centerInside() + .into(imageView) + } + } + } + + private fun formatDuration(durationInSeconds: Double): String? { + val seconds = durationInSeconds.roundToInt().toInt() % 60 + val minutes = durationInSeconds.toInt() % 3600 / 60 + val hours = durationInSeconds.toInt() / 3600 + return String.format("%d:%02d:%02d", hours, minutes, seconds) + } + + private fun getAttachmentDescription(context: Context, attachment: Attachment): CharSequence { + var duration = "" + if (attachment.meta?.duration != null && attachment.meta.duration > 0) { + duration = formatDuration(attachment.meta.duration.toDouble()) + " " + } + return if (TextUtils.isEmpty(attachment.description)) { + duration + context.getString(R.string.description_status_media_no_description_placeholder) + } else { + duration + attachment.description + } + } + + + private fun setAttachmentClickListener(view: View, listener: ChatActionListener, attachment: Attachment, animateTransition: Boolean) { + view.setOnClickListener { v: View -> + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onViewMedia(position, if (animateTransition) v else null) + } + } + view.setOnLongClickListener { v: View -> + val description = getAttachmentDescription(v.context, attachment) + Toast.makeText(v.context, description, Toast.LENGTH_LONG).show() + true + } + } + + + private fun setAttachment(attachment: Attachment?, listener: ChatActionListener) { + if(attachment == null) { + attachmentLayout.visibility = View.GONE + } else { + attachmentLayout.visibility = View.VISIBLE + + val previewUrl = attachment.previewUrl + val description = attachment.description + + if(TextUtils.isEmpty(description)) { + attachmentView.contentDescription = description + } else { + attachmentView.contentDescription = attachmentView.context + .getString(R.string.action_view_media) + } + + loadImage(attachmentView, previewUrl, attachment.meta) + + when(attachment.type) { + Attachment.Type.VIDEO, Attachment.Type.GIFV -> { + mediaOverlay.visibility = View.VISIBLE + } + else -> { + mediaOverlay.visibility = View.GONE + } + } + + setAttachmentClickListener(attachmentView, listener, attachment, true) + } + } + + private fun setCreatedAt(createdAt: Date) { + timestamp.text = sdf.format(createdAt) + } +} + +class ChatMessagesAdapter(private val dataSource : TimelineAdapter.AdapterDataSource, + private val chatActionListener: ChatActionListener, + private val localUserId: String) +: RecyclerView.Adapter() { + + private val VIEW_TYPE_OUR_MESSAGE = 0 + private val VIEW_TYPE_THEIR_MESSAGE = 1 + private val VIEW_TYPE_PLACEHOLDER = 2 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + when(viewType) { + VIEW_TYPE_OUR_MESSAGE -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_our_message, parent, false) + return ChatMessagesViewHolder(view) + } + VIEW_TYPE_THEIR_MESSAGE -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_their_message, parent, false) + return ChatMessagesViewHolder(view) + } + else -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status_placeholder, parent, false) + return PlaceholderViewHolder(view) + } + } + } + + override fun getItemCount(): Int { + return dataSource.itemCount + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(holder, position, null) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payload: MutableList) { + bindViewHolder(holder, position, payload) + } + + private fun bindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList?) { + val chat: ChatMessageViewData = dataSource.getItemAt(position) + if(holder is PlaceholderViewHolder) { + holder.setup(chatActionListener, (chat as ChatMessageViewData.Placeholder).isLoading) + } else if(holder is ChatMessagesViewHolder) { + holder.setupWithChatMessage(chat as ChatMessageViewData.Concrete, chatActionListener, + if (payloads != null && payloads.isNotEmpty()) payloads[0] else null) + } + } + + override fun getItemViewType(position: Int): Int { + if(dataSource.getItemAt(position) is ChatMessageViewData.Concrete) { + val msg = dataSource.getItemAt(position) as ChatMessageViewData.Concrete + + if(msg.accountId == localUserId) { + return VIEW_TYPE_OUR_MESSAGE + } + return VIEW_TYPE_THEIR_MESSAGE + } + return VIEW_TYPE_PLACEHOLDER + } + + override fun getItemId(position: Int): Long { + return dataSource.getItemAt(position).getViewDataId().toLong() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ChatsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ChatsAdapter.kt new file mode 100644 index 0000000..65a19ab --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ChatsAdapter.kt @@ -0,0 +1,209 @@ +package com.keylesspalace.tusky.adapter + +import android.graphics.Typeface +import android.opengl.Visibility +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.text.toSpanned +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.ChatActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.TimestampUtils +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.viewdata.ChatViewData +import java.text.SimpleDateFormat +import java.util.* + +class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) { + object Key { + const val KEY_CREATED = "created" + } + + private val avatar: ImageView = view.findViewById(R.id.status_avatar) + private val avatarInset: ImageView = view.findViewById(R.id.status_avatar_inset) + private val displayName: TextView = view.findViewById(R.id.status_display_name) + private val userName: TextView = view.findViewById(R.id.status_username) + private val timestamp: TextView = view.findViewById(R.id.status_timestamp_info) + private val content: TextView = view.findViewById(R.id.status_content) + private val unread: TextView = view.findViewById(R.id.chat_unread) + + private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) + + fun setupWithChat(chat: ChatViewData.Concrete, + listener: ChatActionListener, + statusDisplayOptions: StatusDisplayOptions, + localUserId: String, + payload: Any?) { + if (payload == null) { + displayName.text = chat.account.displayName?.emojify(chat.account.emojis, displayName, true) + ?: "" + userName.text = userName.context.getString(R.string.status_username_format, chat.account.username) + setUpdatedAt(chat.updatedAt, statusDisplayOptions) + setAvatar(chat.account.avatar, chat.account.bot, statusDisplayOptions) + if (chat.unread <= 0) { + unread.visibility = View.GONE + } else if (chat.unread > 99) { + unread.text = ":)" + } else { + unread.text = chat.unread.toString() + } + avatar.setOnClickListener { listener.onViewAccount(chat.account.id) } + val onLongClickListener = View.OnLongClickListener { + listener.onMore(chat.id, it) + true + } + val onClickListener = View.OnClickListener { + val pos = adapterPosition + if (pos != RecyclerView.NO_POSITION) + listener.openChat(pos) + } + + content.setOnLongClickListener(onLongClickListener) + itemView.setOnLongClickListener(onLongClickListener) + content.setOnClickListener(onClickListener) + itemView.setOnClickListener(onClickListener) + + if(chat.lastMessage != null) { + var text = if (chat.lastMessage.content != null) { + content.setTypeface(null, Typeface.NORMAL) + + chat.lastMessage.content.emojify(chat.lastMessage.emojis, content, true) + } else if (chat.lastMessage.attachment != null) { + content.setTypeface(null, Typeface.ITALIC) + + content.resources.getString(chat.lastMessage.attachment.describeAttachmentType()) + } else if (chat.lastMessage.card != null) { + content.setTypeface(null, Typeface.ITALIC) + + content.resources.getString(R.string.link) + } else "" + + content.text = if(chat.lastMessage.accountId == localUserId) { + SpannableStringBuilder.valueOf(content.resources.getText(R.string.chat_our_last_message)) + .append(": ").append(text) + } else text + + } else { + content.text = "" + } + } else { + if(payload is List<*>) { + for (item in payload as List<*>) { + if (Key.KEY_CREATED == item) { + setUpdatedAt(chat.updatedAt, statusDisplayOptions) + } + } + } + } + } + + private fun setAvatar(url: String, + isBot: Boolean, + statusDisplayOptions: StatusDisplayOptions) { + avatar.setPaddingRelative(0, 0, 0, 0) + if (statusDisplayOptions.showBotOverlay && isBot) { + avatarInset.visibility = View.VISIBLE + avatarInset.setBackgroundColor(0x50ffffff) + Glide.with(avatarInset) + .load(R.drawable.ic_bot_24dp) + .into(avatarInset) + } else { + avatarInset.visibility = View.GONE + } + val avatarRadius = itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp); + loadAvatar(url, avatar, avatarRadius, + statusDisplayOptions.animateAvatars) + } + + + private fun getAbsoluteTime(createdAt: Date?): String? { + if (createdAt == null) { + return "??:??:??" + } + return if (DateUtils.isToday(createdAt.time)) { + shortSdf.format(createdAt) + } else { + longSdf.format(createdAt) + } + } + + private fun setUpdatedAt(updatedAt: Date, statusDisplayOptions: StatusDisplayOptions) { + if (statusDisplayOptions.useAbsoluteTime) { + timestamp.text = getAbsoluteTime(updatedAt) + } else { + val then = updatedAt.time + val now = System.currentTimeMillis() + val readout = TimestampUtils.getRelativeTimeSpanString(timestamp.context, then, now) + timestamp.text = readout + } + } + +} + +class ChatsAdapter(private val dataSource: TimelineAdapter.AdapterDataSource, + val statusDisplayOptions: StatusDisplayOptions, + private val chatActionListener: ChatActionListener, + val localUserId: String) : RecyclerView.Adapter() { + + private val VIEW_TYPE_CHAT = 0 + private val VIEW_TYPE_PLACEHOLDER = 1 + + override fun getItemCount(): Int { + return dataSource.itemCount + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(holder, position, null) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payload: MutableList) { + bindViewHolder(holder, position, payload) + } + + private fun bindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList?) { + val chat: ChatViewData = dataSource.getItemAt(position) + if(holder is PlaceholderViewHolder) { + holder.setup(chatActionListener, (chat as ChatViewData.Placeholder).isLoading) + } else if(holder is ChatsViewHolder) { + holder.setupWithChat(chat as ChatViewData.Concrete, chatActionListener, + statusDisplayOptions, localUserId, + if (payloads != null && payloads.isNotEmpty()) payloads[0] else null) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + if(viewType == VIEW_TYPE_CHAT ) { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_chat, parent, false) + return ChatsViewHolder(view) + } + // else VIEW_TYPE_PLACEHOLDER + + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status_placeholder, parent, false) + return PlaceholderViewHolder(view) + } + + override fun getItemViewType(position: Int): Int { + if(dataSource.getItemAt(position) is ChatViewData.Concrete) + return VIEW_TYPE_CHAT + + return VIEW_TYPE_PLACEHOLDER + } + + override fun getItemId(position: Int): Long { + return dataSource.getItemAt(position).getViewDataId().toLong() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt new file mode 100644 index 0000000..70a6163 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -0,0 +1,64 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import java.util.* + +class EmojiAdapter(emojiList: List, private val onEmojiSelectedListener: OnEmojiSelectedListener) : RecyclerView.Adapter() { + private val emojiList : List + + init { + this.emojiList = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } + .sortedBy { it.shortcode.toLowerCase(Locale.ROOT) } + } + + override fun getItemCount(): Int { + return emojiList.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_emoji_button, parent, false) as ImageView + return EmojiHolder(view) + } + + override fun onBindViewHolder(viewHolder: EmojiHolder, position: Int) { + val emoji = emojiList[position] + + Glide.with(viewHolder.emojiImageView) + .load(emoji.url) + .into(viewHolder.emojiImageView) + + viewHolder.emojiImageView.setOnClickListener { + onEmojiSelectedListener.onEmojiSelected(emoji.shortcode) + } + + viewHolder.emojiImageView.contentDescription = emoji.shortcode + } + + class EmojiHolder(val emojiImageView: ImageView) : RecyclerView.ViewHolder(emojiImageView) + +} + +interface OnEmojiSelectedListener { + fun onEmojiSelected(shortcode: String) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiReactionsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiReactionsAdapter.java new file mode 100644 index 0000000..c8491ad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiReactionsAdapter.java @@ -0,0 +1,72 @@ +package com.keylesspalace.tusky.adapter; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.emoji.widget.EmojiAppCompatButton; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.EmojiReaction; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.text.DateFormat; +import java.util.List; +import java.util.Date; + + +public class EmojiReactionsAdapter extends RecyclerView.Adapter { + private final List reactions; + private final StatusActionListener listener; + private final String statusId; + + EmojiReactionsAdapter(final List reactions, final StatusActionListener listener, final String statusId) { + this.reactions = reactions; + this.listener = listener; + this.statusId = statusId; + } + + @Override + public SingleViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_emoji_reaction, parent, false); + return new SingleViewHolder(view); + } + + @Override + public void onBindViewHolder(SingleViewHolder holder, int position) { + EmojiReaction reaction = reactions.get(position); + String str = reaction.getName() + " " + reaction.getCount(); + + // no custom emoji yet! + EmojiAppCompatButton btn = (EmojiAppCompatButton)holder.itemView; + + btn.setText(str); + btn.setActivated(reaction.getMe()); + btn.setOnClickListener(v -> { + listener.onEmojiReactMenu(v, reaction, statusId); + }); + } + + // total number of rows + @Override + public int getItemCount() { + return reactions.size(); + } +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java new file mode 100644 index 0000000..8215874 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java @@ -0,0 +1,61 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.AccountActionListener; + +/** Both for follows and following lists. */ +public class FollowAdapter extends AccountAdapter { + + public FollowAdapter(AccountActionListener accountActionListener) { + super(accountActionListener); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_ACCOUNT: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_account, parent, false); + return new AccountViewHolder(view); + } + case VIEW_TYPE_FOOTER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + return new LoadingFooterViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { + AccountViewHolder holder = (AccountViewHolder) viewHolder; + holder.setupWithAccount(accountList.get(position)); + holder.setupActionListener(accountActionListener); + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt new file mode 100644 index 0000000..e39e500 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -0,0 +1,58 @@ +package com.keylesspalace.tusky.adapter + +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StyleSpan +import android.view.View +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.util.visible +import kotlinx.android.synthetic.main.item_follow_request_notification.view.* + +internal class FollowRequestViewHolder(itemView: View, private val showHeader: Boolean) : RecyclerView.ViewHolder(itemView) { + private var id: String? = null + private val animateAvatar: Boolean = PreferenceManager.getDefaultSharedPreferences(itemView.context) + .getBoolean("animateGifAvatars", false) + + fun setupWithAccount(account: Account) { + id = account.id + val wrappedName = account.name.unicodeWrap() + val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, true) + itemView.displayNameTextView.text = emojifiedName + if (showHeader) { + val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) + itemView.notificationTextView?.text = SpannableStringBuilder(wholeMessage).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + }.emojify(account.emojis, itemView) + } + itemView.notificationTextView?.visible(showHeader) + val format = itemView.context.getString(R.string.status_username_format) + val formattedUsername = String.format(format, account.username) + itemView.usernameTextView.text = formattedUsername + val avatarRadius = itemView.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, itemView.avatar, avatarRadius, animateAvatar) + } + + fun setupActionListener(listener: AccountActionListener) { + itemView.acceptButton.setOnClickListener { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onRespondToFollowRequest(true, id, position) + } + } + itemView.rejectButton.setOnClickListener { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onRespondToFollowRequest(false, id, position) + } + } + itemView.setOnClickListener { listener.onViewAccount(id) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java new file mode 100644 index 0000000..dab3d4f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java @@ -0,0 +1,60 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.AccountActionListener; + +public class FollowRequestsAdapter extends AccountAdapter { + + public FollowRequestsAdapter(AccountActionListener accountActionListener) { + super(accountActionListener); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_ACCOUNT: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_follow_request, parent, false); + return new FollowRequestViewHolder(view, false); + } + case VIEW_TYPE_FOOTER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + return new LoadingFooterViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { + FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; + holder.setupWithAccount(accountList.get(position)); + holder.setupActionListener(accountActionListener); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/HashtagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/HashtagViewHolder.kt new file mode 100644 index 0000000..c70076c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/HashtagViewHolder.kt @@ -0,0 +1,16 @@ +package com.keylesspalace.tusky.adapter + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.interfaces.LinkListener + +class HashtagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val hashtag: TextView = itemView.findViewById(R.id.hashtag) + + fun setup(tag: String, listener: LinkListener) { + hashtag.text = String.format("#%s", tag) + hashtag.setOnClickListener { listener.onViewTag(tag) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt new file mode 100644 index 0000000..f9b19c6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt @@ -0,0 +1,41 @@ +/* Copyright 2019 kyori19 + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.MastoList +import kotlinx.android.synthetic.main.item_picker_list.view.* + +class ListSelectionAdapter(context: Context) : ArrayAdapter(context, R.layout.item_autocomplete_hashtag) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + + val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + + val view = convertView + ?: layoutInflater.inflate(R.layout.item_picker_list, parent, false) + + getItem(position)?.let { list -> + view.title.text = list.title + } + + return view + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt new file mode 100644 index 0000000..ebff5c5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt @@ -0,0 +1,21 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import android.view.View + +class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutedStatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/MutedStatusViewHolder.java new file mode 100644 index 0000000..66138b2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutedStatusViewHolder.java @@ -0,0 +1,170 @@ +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import android.text.format.DateUtils; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.TimestampUtils; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class MutedStatusViewHolder extends RecyclerView.ViewHolder { + public static class Key { + public static final String KEY_CREATED = "created"; + } + + private TextView displayName; + private TextView username; + private ImageButton unmuteButton; + public TextView timestampInfo; + + private SimpleDateFormat shortSdf; + private SimpleDateFormat longSdf; + + protected MutedStatusViewHolder(View itemView) { + super(itemView); + displayName = itemView.findViewById(R.id.status_display_name); + username = itemView.findViewById(R.id.status_username); + timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + unmuteButton = itemView.findViewById(R.id.status_toggle_mute); + + this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); + this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); + } + + protected void setDisplayName(String name, List customEmojis) { + CharSequence emojifiedName = CustomEmojiHelper.emojify(name, customEmojis, displayName, true); + displayName.setText(emojifiedName); + } + + protected void setUsername(String name) { + Context context = username.getContext(); + String usernameText = context.getString(R.string.status_username_format, name); + username.setText(usernameText); + } + + protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { + if (statusDisplayOptions.useAbsoluteTime()) { + timestampInfo.setText(getAbsoluteTime(createdAt)); + } else { + if (createdAt == null) { + timestampInfo.setText("?m"); + } else { + long then = createdAt.getTime(); + long now = System.currentTimeMillis(); + String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + timestampInfo.setText(readout); + } + } + } + + private String getAbsoluteTime(Date createdAt) { + if (createdAt == null) { + return "??:??:??"; + } + if (DateUtils.isToday(createdAt.getTime())) { + return shortSdf.format(createdAt); + } else { + return longSdf.format(createdAt); + } + } + + private CharSequence getCreatedAtDescription(Date createdAt, + StatusDisplayOptions statusDisplayOptions) { + if (statusDisplayOptions.useAbsoluteTime()) { + return getAbsoluteTime(createdAt); + } else { + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + + if (createdAt == null) { + return "? minutes"; + } else { + long then = createdAt.getTime(); + long now = System.currentTimeMillis(); + return DateUtils.getRelativeTimeSpanString(then, now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE); + } + } + } + + private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, + StatusDisplayOptions statusDisplayOptions) { + Context context = itemView.getContext(); + + String description = context.getString(R.string.description_muted_status, + status.getUserFullName(), + getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions), + status.getNickname() + ); + itemView.setContentDescription(description); + } + + + protected void setupButtons(final StatusActionListener listener, final String accountId) { + + unmuteButton.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onMute(position, false); + } + }); + + itemView.setOnClickListener( v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onViewThread(position); + } + }); + } + + public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions) { + this.setupWithStatus(status, listener, statusDisplayOptions, null); + } + + protected void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { + if (payloads == null) { + setDisplayName(status.getUserFullName(), status.getAccountEmojis()); + setUsername(status.getNickname()); + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + + setupButtons(listener, status.getSenderId()); + setDescriptionForStatus(status, statusDisplayOptions); + + // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 + // RecyclerView tries to set AccessibilityDelegateCompat to null + // but ViewCompat code replaces is with the default one. RecyclerView never + // fetches another one from its delegate because it checks that it's set so we remove it + // and let RecyclerView ask for a new delegate. + itemView.setAccessibilityDelegate(null); + } else { + if (payloads instanceof List) + for (Object item : (List) payloads) { + if (Key.KEY_CREATED.equals(item)) { + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + } + } + + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java new file mode 100644 index 0000000..c4224c9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java @@ -0,0 +1,135 @@ +package com.keylesspalace.tusky.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; + +import java.util.HashMap; + +public class MutesAdapter extends AccountAdapter { + private HashMap mutingNotificationsMap; + + public MutesAdapter(AccountActionListener accountActionListener) { + super(accountActionListener); + mutingNotificationsMap = new HashMap(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_ACCOUNT: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_muted_user, parent, false); + return new MutesAdapter.MutedUserViewHolder(view); + } + case VIEW_TYPE_FOOTER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + return new LoadingFooterViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { + MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder; + Account account = accountList.get(position); + holder.setupWithAccount(account, mutingNotificationsMap.get(account.getId())); + holder.setupActionListener(accountActionListener); + } + } + + public void updateMutingNotifications(String id, boolean mutingNotifications, int position) { + mutingNotificationsMap.put(id, mutingNotifications); + notifyItemChanged(position); + } + + public void updateMutingNotificationsMap(HashMap newMutingNotificationsMap) { + mutingNotificationsMap.putAll(newMutingNotificationsMap); + notifyDataSetChanged(); + } + + static class MutedUserViewHolder extends RecyclerView.ViewHolder { + private ImageView avatar; + private TextView username; + private TextView displayName; + private ImageButton unmute; + private ImageButton muteNotifications; + private String id; + private boolean animateAvatar; + private boolean notifications; + + MutedUserViewHolder(View itemView) { + super(itemView); + avatar = itemView.findViewById(R.id.muted_user_avatar); + username = itemView.findViewById(R.id.muted_user_username); + displayName = itemView.findViewById(R.id.muted_user_display_name); + unmute = itemView.findViewById(R.id.muted_user_unmute); + muteNotifications = itemView.findViewById(R.id.muted_user_mute_notifications); + animateAvatar = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()) + .getBoolean("animateGifAvatars", false); + } + + void setupWithAccount(Account account, Boolean mutingNotifications) { + id = account.getId(); + CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName); + displayName.setText(emojifiedName); + String format = username.getContext().getString(R.string.status_username_format); + String formattedUsername = String.format(format, account.getUsername()); + username.setText(formattedUsername); + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_48dp); + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); + + String unmuteString = unmute.getContext().getString(R.string.action_unmute_desc, formattedUsername); + unmute.setContentDescription(unmuteString); + ViewCompat.setTooltipText(unmute, unmuteString); + + if (mutingNotifications == null) { + muteNotifications.setEnabled(false); + notifications = true; + } else { + muteNotifications.setEnabled(true); + notifications = mutingNotifications; + } + + if (notifications) { + muteNotifications.setImageResource(R.drawable.ic_notifications_24dp); + String unmuteNotificationsString = muteNotifications.getContext() + .getString(R.string.action_unmute_notifications_desc, formattedUsername); + muteNotifications.setContentDescription(unmuteNotificationsString); + ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString); + } else { + muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp); + String muteNotificationsString = muteNotifications.getContext() + .getString(R.string.action_mute_notifications_desc, formattedUsername); + muteNotifications.setContentDescription(muteNotificationsString); + ViewCompat.setTooltipText(muteNotifications, muteNotificationsString); + } + } + + void setupActionListener(final AccountActionListener listener) { + unmute.setOnClickListener(v -> listener.onMute(false, id, getAdapterPosition(), false)); + muteNotifications.setOnClickListener( + v -> listener.onMute(true, id, getAdapterPosition(), !notifications)); + itemView.setOnClickListener(v -> listener.onViewAccount(id)); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt new file mode 100644 index 0000000..66065c7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt @@ -0,0 +1,45 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import android.view.View +import android.view.ViewGroup +import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.Status +import com.keylesspalace.tusky.util.visible +import kotlinx.android.synthetic.main.item_network_state.view.* + +class NetworkStateViewHolder(itemView: View, + private val retryCallback: () -> Unit) +: RecyclerView.ViewHolder(itemView) { + + fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { + itemView.progressBar.visible(state?.status == Status.RUNNING) + itemView.retryButton.visible(state?.status == Status.FAILED) + itemView.errorMsg.visible(state?.msg != null) + itemView.errorMsg.text = state?.msg + itemView.retryButton.setOnClickListener { + retryCallback() + } + if(fullScreen) { + itemView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + } else { + itemView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java new file mode 100644 index 0000000..51fb5e1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -0,0 +1,734 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Typeface; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.text.InputFilter; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.LinkListener; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.StringUtils; +import com.keylesspalace.tusky.util.ThemeUtils; +import com.keylesspalace.tusky.util.TimestampUtils; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import at.connyduck.sparkbutton.helpers.Utils; + +public class NotificationsAdapter extends RecyclerView.Adapter { + + public interface AdapterDataSource { + int getItemCount(); + + T getItemAt(int pos); + } + + + private static final int VIEW_TYPE_STATUS = 0; + private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; + private static final int VIEW_TYPE_FOLLOW = 2; + private static final int VIEW_TYPE_PLACEHOLDER = 3; + private static final int VIEW_TYPE_MUTED_STATUS = 4; + private static final int VIEW_TYPE_FOLLOW_REQUEST = 5; + private static final int VIEW_TYPE_MOVE = 6; + private static final int VIEW_TYPE_UNKNOWN = 7; + + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + + private String accountId; + private StatusDisplayOptions statusDisplayOptions; + private StatusActionListener statusListener; + private NotificationActionListener notificationActionListener; + private AccountActionListener accountActionListener; + private AdapterDataSource dataSource; + + public NotificationsAdapter(String accountId, + AdapterDataSource dataSource, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener statusListener, + NotificationActionListener notificationActionListener, + AccountActionListener accountActionListener) { + + this.accountId = accountId; + this.dataSource = dataSource; + this.statusDisplayOptions = statusDisplayOptions; + this.statusListener = statusListener; + this.notificationActionListener = notificationActionListener; + this.accountActionListener = accountActionListener; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case VIEW_TYPE_STATUS: { + View view = inflater + .inflate(R.layout.item_status, parent, false); + return new StatusViewHolder(view); + } + case VIEW_TYPE_MUTED_STATUS: { + View view = inflater + .inflate(R.layout.item_status_muted, parent, false); + return new MutedStatusViewHolder(view); + } + case VIEW_TYPE_STATUS_NOTIFICATION: { + View view = inflater + .inflate(R.layout.item_status_notification, parent, false); + return new StatusNotificationViewHolder(view, statusDisplayOptions); + } + case VIEW_TYPE_MOVE: + case VIEW_TYPE_FOLLOW: { + View view = inflater + .inflate(R.layout.item_follow, parent, false); + return new FollowViewHolder(view, statusDisplayOptions); + } + case VIEW_TYPE_FOLLOW_REQUEST: { + View view = inflater + .inflate(R.layout.item_follow_request_notification, parent, false); + return new FollowRequestViewHolder(view, true); + } + case VIEW_TYPE_PLACEHOLDER: { + View view = inflater + .inflate(R.layout.item_status_placeholder, parent, false); + return new PlaceholderViewHolder(view); + } + default: + case VIEW_TYPE_UNKNOWN: { + View view = new View(parent.getContext()); + view.setLayoutParams( + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + Utils.dpToPx(parent.getContext(), 24) + ) + ); + return new RecyclerView.ViewHolder(view) { + }; + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + bindViewHolder(viewHolder, position, null); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { + bindViewHolder(viewHolder, position, payloads); + } + + private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { + Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; + if (position < this.dataSource.getItemCount()) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Placeholder) { + if (payloadForHolder == null) { + NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(statusListener, placeholder.isLoading()); + } + return; + } + NotificationViewData.Concrete concreteNotificaton = + (NotificationViewData.Concrete) notification; + switch (viewHolder.getItemViewType()) { + case VIEW_TYPE_STATUS: { + StatusViewHolder holder = (StatusViewHolder) viewHolder; + StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); + holder.setupWithStatus(status, + statusListener, statusDisplayOptions, payloadForHolder); + if (concreteNotificaton.getType() == Notification.Type.POLL) { + holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); + } else { + holder.hideStatusInfo(); + } + break; + } + case VIEW_TYPE_MUTED_STATUS: { + MutedStatusViewHolder holder = (MutedStatusViewHolder) viewHolder; + StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); + holder.setupWithStatus(status, + statusListener, statusDisplayOptions, payloadForHolder); + break; + } + case VIEW_TYPE_STATUS_NOTIFICATION: { + StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; + StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData(); + if (payloadForHolder == null) { + if (statusViewData == null) { + holder.showNotificationContent(false); + } else { + holder.showNotificationContent(true); + + holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis()); + holder.setUsername(statusViewData.getNickname()); + holder.setCreatedAt(statusViewData.getCreatedAt()); + + if(concreteNotificaton.getType() == Notification.Type.STATUS) { + holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot()); + } else { + holder.setAvatars(statusViewData.getAvatar(), + concreteNotificaton.getAccount().getAvatar()); + } + } + + holder.setMessage(concreteNotificaton, statusListener); + holder.setupButtons(notificationActionListener, + concreteNotificaton.getAccount().getId(), + concreteNotificaton.getId()); + } else { + if (payloadForHolder instanceof List) + for (Object item : (List) payloadForHolder) { + if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { + holder.setCreatedAt(statusViewData.getCreatedAt()); + } + } + } + break; + } + case VIEW_TYPE_FOLLOW: { + if (payloadForHolder == null) { + FollowViewHolder holder = (FollowViewHolder) viewHolder; + holder.setMessage(concreteNotificaton.getAccount(), null); + holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); + } + break; + } + case VIEW_TYPE_MOVE: { + if (payloadForHolder == null) { + FollowViewHolder holder = (FollowViewHolder) viewHolder; + holder.setMessage(concreteNotificaton.getTarget(), concreteNotificaton.getAccount()); + holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); + } + break; + } + case VIEW_TYPE_FOLLOW_REQUEST: { + if (payloadForHolder == null) { + FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; + holder.setupWithAccount(concreteNotificaton.getAccount()); + holder.setupActionListener(accountActionListener); + } + } + default: + } + } + } + + @Override + public int getItemCount() { + return dataSource.getItemCount(); + } + + public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { + this.statusDisplayOptions = statusDisplayOptions.copy( + statusDisplayOptions.animateAvatars(), + mediaPreviewEnabled, + statusDisplayOptions.useAbsoluteTime(), + statusDisplayOptions.showBotOverlay(), + statusDisplayOptions.useBlurhash(), + CardViewMode.NONE, + statusDisplayOptions.confirmReblogs(), + statusDisplayOptions.renderStatusAsMention(), + statusDisplayOptions.hideStats() + ); + } + + public boolean isMediaPreviewEnabled() { + return this.statusDisplayOptions.mediaPreviewEnabled(); + } + + @Override + public int getItemViewType(int position) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Concrete) { + NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); + switch (concrete.getType()) { + case MENTION: + case POLL: { + if (concrete.getStatusViewData() != null && concrete.getStatusViewData().isMuted()) + return VIEW_TYPE_MUTED_STATUS; + return VIEW_TYPE_STATUS; + } + case STATUS: + if (statusDisplayOptions.renderStatusAsMention()) { + if (concrete.getStatusViewData() != null && concrete.getStatusViewData().isMuted()) + return VIEW_TYPE_MUTED_STATUS; + return VIEW_TYPE_STATUS; + } + /* fallthrough */ + case FAVOURITE: + case REBLOG: + case EMOJI_REACTION: { + return VIEW_TYPE_STATUS_NOTIFICATION; + } + case FOLLOW: { + return VIEW_TYPE_FOLLOW; + } + case FOLLOW_REQUEST: { + return VIEW_TYPE_FOLLOW_REQUEST; + } + case MOVE: { + return VIEW_TYPE_MOVE; + } + default: { + return VIEW_TYPE_UNKNOWN; + } + } + } else if (notification instanceof NotificationViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + throw new AssertionError("Unknown notification type"); + } + + + } + + public interface NotificationActionListener { + void onViewAccount(String id); + + void onViewStatusForNotificationId(String notificationId); + + void onExpandedChange(boolean expanded, int position); + + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onNotificationContentCollapsedChange(boolean isCollapsed, int position); + + void onViewReplyTo(int position); + } + + private static class FollowViewHolder extends RecyclerView.ViewHolder { + private TextView message; + private TextView usernameView; + private TextView displayNameView; + private ImageView avatar; + private StatusDisplayOptions statusDisplayOptions; + + FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + super(itemView); + message = itemView.findViewById(R.id.notification_text); + usernameView = itemView.findViewById(R.id.notification_username); + displayNameView = itemView.findViewById(R.id.notification_display_name); + avatar = itemView.findViewById(R.id.notification_avatar); + this.statusDisplayOptions = statusDisplayOptions; + } + + void setMessage(Account account, @Nullable Account from) { + Context context = message.getContext(); + + String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); + Drawable drawable; + CharSequence emojifiedMessage; + + if(from != null) { + String format = context.getString(R.string.notification_move_format); + String wrappedFromName = StringUtils.unicodeWrap(from.getName()); + String wholeMessage = String.format(format, wrappedFromName); + emojifiedMessage = CustomEmojiHelper.emojify(wholeMessage, from.getEmojis(), message, true); + + drawable = ContextCompat.getDrawable(context, R.drawable.ic_reply_24dp); + } else { + String format = context.getString(R.string.notification_follow_format); + String wholeMessage = String.format(format, wrappedDisplayName); + emojifiedMessage = CustomEmojiHelper.emojify(wholeMessage, account.getEmojis(), message, true); + + drawable = ContextCompat.getDrawable(context, R.drawable.ic_person_add_24dp); + } + + message.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null); + + message.setText(emojifiedMessage); + + String username = context.getString(R.string.status_username_format, account.getUsername()); + usernameView.setText(username); + + CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(wrappedDisplayName, account.getEmojis(), usernameView, true); + + displayNameView.setText(emojifiedDisplayName); + + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_42dp); + + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, + statusDisplayOptions.animateAvatars()); + + } + + void setupButtons(final NotificationActionListener listener, final String accountId) { + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); + } + } + + + private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener { + private final TextView message; + private final View statusNameBar; + private final TextView displayName; + private final TextView username; + private final TextView timestampInfo; + private final TextView statusContent; + private final ImageView statusAvatar; + private final ImageView notificationAvatar; + private final TextView replyInfo; + private final TextView contentWarningDescriptionTextView; + private final Button contentWarningButton; + private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder + private StatusDisplayOptions statusDisplayOptions; + + private String accountId; + private String notificationId; + private NotificationActionListener notificationActionListener; + private StatusViewData.Concrete statusViewData; + private SimpleDateFormat shortSdf; + private SimpleDateFormat longSdf; + + private int avatarRadius48dp; + private int avatarRadius36dp; + private int avatarRadius24dp; + + StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + super(itemView); + message = itemView.findViewById(R.id.notification_top_text); + statusNameBar = itemView.findViewById(R.id.status_name_bar); + displayName = itemView.findViewById(R.id.status_display_name); + username = itemView.findViewById(R.id.status_username); + timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + statusContent = itemView.findViewById(R.id.notification_content); + statusAvatar = itemView.findViewById(R.id.notification_status_avatar); + notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); + replyInfo = itemView.findViewById(R.id.notification_reply_info); + contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); + contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); + this.statusDisplayOptions = statusDisplayOptions; + + int darkerFilter = Color.rgb(123, 123, 123); + statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + + itemView.setOnClickListener(this); + message.setOnClickListener(this); + statusContent.setOnClickListener(this); + shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); + longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); + + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); + this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); + this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); + } + + private void showNotificationContent(boolean show) { + statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); + statusContent.setVisibility(show ? View.VISIBLE : View.GONE); + statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + replyInfo.setVisibility(show ? View.VISIBLE : View.GONE); + } + + private void setDisplayName(String name, List emojis) { + CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, true); + displayName.setText(emojifiedName); + } + + private void setUsername(String name) { + Context context = username.getContext(); + String format = context.getString(R.string.status_username_format); + String usernameText = String.format(format, name); + username.setText(usernameText); + } + + protected void setCreatedAt(@Nullable Date createdAt) { + if (statusDisplayOptions.useAbsoluteTime()) { + String time; + if (createdAt != null) { + if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { + time = longSdf.format(createdAt); + } else { + time = shortSdf.format(createdAt); + } + } else { + time = "??:??:??"; + } + timestampInfo.setText(time); + } else { + // This is the visible timestampInfo. + String readout; + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + CharSequence readoutAloud; + if (createdAt != null) { + long then = createdAt.getTime(); + long now = new Date().getTime(); + readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, + android.text.format.DateUtils.SECOND_IN_MILLIS, + android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); + } else { + // unknown minutes~ + readout = "?m"; + readoutAloud = "? minutes"; + } + timestampInfo.setText(readout); + timestampInfo.setContentDescription(readoutAloud); + } + } + + void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { + this.statusViewData = notificationViewData.getStatusViewData(); + + String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); + Notification.Type type = notificationViewData.getType(); + + Context context = message.getContext(); + String wholeMessage; + Drawable icon; + switch (type) { + default: + case FAVOURITE: { + icon = ContextCompat.getDrawable(context, R.drawable.ic_star_24dp); + if (icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, + R.color.tusky_orange), PorterDuff.Mode.SRC_ATOP); + } + + String format = context.getString(R.string.notification_favourite_format); + wholeMessage = String.format(format, displayName); + break; + } + case REBLOG: { + icon = ContextCompat.getDrawable(context, R.drawable.ic_repeat_24dp); + if (icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, + R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP); + } + + String format = context.getString(R.string.notification_reblog_format); + wholeMessage = String.format(format, displayName); + break; + } + case STATUS: { + icon = ContextCompat.getDrawable(context, R.drawable.ic_home_24dp); + if (icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, + R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP); + } + + String format = context.getString(R.string.notification_subscription_format); + wholeMessage = String.format(format, displayName); + break; + } + case EMOJI_REACTION: { + icon = ContextCompat.getDrawable(context, R.drawable.ic_emoji_24dp); + if(icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, + R.color.tusky_green), PorterDuff.Mode.SRC_ATOP); + } + + String format = context.getString(R.string.notification_emoji_format); + String emojiCode = notificationViewData.getEmoji(); + wholeMessage = String.format(format, displayName, emojiCode); + break; + } + } + message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); + final SpannableString str = new SpannableString(wholeMessage); + str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + CharSequence emojifiedText = CustomEmojiHelper.emojify(str, notificationViewData.getAccount().getEmojis(), message, true); + message.setText(emojifiedText); + + if (statusViewData != null) { + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); + contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); + if (statusViewData.isExpanded()) { + contentWarningButton.setText(R.string.status_content_warning_show_less); + } else { + contentWarningButton.setText(R.string.status_content_warning_show_more); + } + + contentWarningButton.setOnClickListener(view -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getAdapterPosition()); + } + statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); + }); + + setupContentAndSpoiler(listener); + setupReplyInfo(); + } + + } + + void setupButtons(final NotificationActionListener listener, final String accountId, + final String notificationId) { + this.notificationActionListener = listener; + this.accountId = accountId; + this.notificationId = notificationId; + } + + void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { + statusAvatar.setPaddingRelative(0, 0, 0, 0); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars()); + + if (statusDisplayOptions.showBotOverlay() && isBot) { + notificationAvatar.setVisibility(View.VISIBLE); + notificationAvatar.setBackgroundColor(0x50ffffff); + Glide.with(notificationAvatar) + .load(R.drawable.ic_bot_24dp) + .into(notificationAvatar); + + } else { + notificationAvatar.setVisibility(View.GONE); + } + } + + void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { + int padding = Utils.dpToPx(statusAvatar.getContext(), 12); + statusAvatar.setPaddingRelative(0, 0, padding, padding); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars()); + + notificationAvatar.setVisibility(View.VISIBLE); + ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, + avatarRadius24dp, statusDisplayOptions.animateAvatars()); + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.notification_container: + case R.id.notification_content: + if (notificationActionListener != null) + notificationActionListener.onViewStatusForNotificationId(notificationId); + break; + case R.id.notification_top_text: + if (notificationActionListener != null) + notificationActionListener.onViewAccount(accountId); + break; + } + } + + private void setupContentAndSpoiler(final LinkListener listener) { + boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); + if (!shouldShowContentIfSpoiler && hasSpoiler) { + statusContent.setVisibility(View.GONE); + } else { + statusContent.setVisibility(View.VISIBLE); + } + + Spanned content = statusViewData.getContent(); + List emojis = statusViewData.getStatusEmojis(); + + if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { + contentCollapseButton.setOnClickListener(view -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { + notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); + } + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (statusViewData.isCollapsed()) { + contentCollapseButton.setText(R.string.status_content_warning_show_more); + statusContent.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setText(R.string.status_content_warning_show_less); + statusContent.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + statusContent.setFilters(NO_INPUT_FILTER); + } + + CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, statusContent); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); + + CharSequence emojifiedContentWarning = + CustomEmojiHelper.emojify(statusViewData.getSpoilerText(), statusViewData.getStatusEmojis(), contentWarningDescriptionTextView); + contentWarningDescriptionTextView.setText(emojifiedContentWarning); + } + + private void setupReplyInfo() { + if (statusViewData.getInReplyToId() != null) { + Context context = replyInfo.getContext(); + String replyToAccount = statusViewData.getInReplyToAccountAcct(); + replyInfo.setText(context.getString(R.string.status_replied_to_format, replyToAccount)); + if (!statusViewData.getParentVisible()) { + replyInfo.setPaintFlags(replyInfo.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + replyInfo.setOnClickListener(null); + } else { + replyInfo.setPaintFlags(replyInfo.getPaintFlags() & (~Paint.STRIKE_THRU_TEXT_FLAG)); + replyInfo.setOnClickListener(v -> notificationActionListener.onViewReplyTo(getAdapterPosition())); + } + replyInfo.setVisibility(View.VISIBLE); + } else { + replyInfo.setVisibility(View.GONE); + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java new file mode 100644 index 0000000..403fc7d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java @@ -0,0 +1,60 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.ChatActionListener; +import com.keylesspalace.tusky.interfaces.StatusActionListener; + +public final class PlaceholderViewHolder extends RecyclerView.ViewHolder { + + private Button loadMoreButton; + private ProgressBar progressBar; + + PlaceholderViewHolder(View itemView) { + super(itemView); + loadMoreButton = itemView.findViewById(R.id.button_load_more); + progressBar = itemView.findViewById(R.id.progressBar); + } + + private void setup(boolean progress) { + loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE); + progressBar.setVisibility(progress ? View.VISIBLE : View.GONE); + + loadMoreButton.setEnabled(true); + } + + public void setup(final StatusActionListener listener, boolean progress) { + setup(progress); + loadMoreButton.setOnClickListener(v -> { + loadMoreButton.setEnabled(false); + listener.onLoadMore(getAdapterPosition()); + }); + } + + public void setup(final ChatActionListener listener, boolean progress) { + setup(progress); + loadMoreButton.setOnClickListener( v -> { + loadMoreButton.setEnabled(false); + listener.onLoadMore(getAdapterPosition()); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt new file mode 100644 index 0000000..6255a88 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -0,0 +1,130 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.RadioButton +import android.widget.TextView +import androidx.emoji.text.EmojiCompat +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewdata.PollOptionViewData +import com.keylesspalace.tusky.viewdata.buildDescription +import com.keylesspalace.tusky.viewdata.calculatePercent + +class PollAdapter: RecyclerView.Adapter() { + + private var pollOptions: List = emptyList() + private var voteCount: Int = 0 + private var votersCount: Int? = null + private var mode = RESULT + private var emojis: List = emptyList() + private var resultClickListener: View.OnClickListener? = null + + fun setup( + options: List, + voteCount: Int, + votersCount: Int?, + emojis: List, + mode: Int, + resultClickListener: View.OnClickListener?) { + this.pollOptions = options + this.voteCount = voteCount + this.votersCount = votersCount + this.emojis = emojis + this.mode = mode + this.resultClickListener = resultClickListener + notifyDataSetChanged() + } + + fun getSelected() : List { + return pollOptions.filter { it.selected } + .map { pollOptions.indexOf(it) } + } + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PollViewHolder { + return PollViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll, parent, false)) + } + + override fun getItemCount(): Int { + return pollOptions.size + } + + override fun onBindViewHolder(holder: PollViewHolder, position: Int) { + + val option = pollOptions[position] + + holder.resultTextView.visible(mode == RESULT) + holder.radioButton.visible(mode == SINGLE) + holder.checkBox.visible(mode == MULTIPLE) + + when(mode) { + RESULT -> { + val percent = calculatePercent(option.votesCount, votersCount, voteCount) + val emojifiedPollOptionText = buildDescription(option.title, percent, holder.resultTextView.context) + .emojify(emojis, holder.resultTextView) + holder.resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) + + val level = percent * 100 + + holder.resultTextView.background.level = level + holder.resultTextView.setOnClickListener(resultClickListener) + } + SINGLE -> { + val emojifiedPollOptionText = option.title.emojify(emojis, holder.radioButton) + holder.radioButton.text = EmojiCompat.get().process(emojifiedPollOptionText) + holder.radioButton.isChecked = option.selected + holder.radioButton.setOnClickListener { + pollOptions.forEachIndexed { index, pollOption -> + pollOption.selected = index == holder.adapterPosition + notifyItemChanged(index) + } + } + } + MULTIPLE -> { + val emojifiedPollOptionText = option.title.emojify(emojis, holder.checkBox) + holder.checkBox.text = EmojiCompat.get().process(emojifiedPollOptionText) + holder.checkBox.isChecked = option.selected + holder.checkBox.setOnCheckedChangeListener { _, isChecked -> + pollOptions[holder.adapterPosition].selected = isChecked + } + } + } + + } + + companion object { + const val RESULT = 0 + const val SINGLE = 1 + const val MULTIPLE = 2 + } +} + + + +class PollViewHolder(view: View): RecyclerView.ViewHolder(view) { + + val resultTextView: TextView = view.findViewById(R.id.status_poll_option_result) + val radioButton: RadioButton = view.findViewById(R.id.status_poll_radio_button) + val checkBox: CheckBox = view.findViewById(R.id.status_poll_checkbox) + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt new file mode 100644 index 0000000..328e962 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt @@ -0,0 +1,67 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R + +class PreviewPollOptionsAdapter: RecyclerView.Adapter() { + + private var options: List = emptyList() + private var multiple: Boolean = false + private var clickListener: View.OnClickListener? = null + + fun update(newOptions: List, multiple: Boolean) { + this.options = newOptions + this.multiple = multiple + notifyDataSetChanged() + } + + fun setOnClickListener(l: View.OnClickListener?) { + clickListener = l + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { + return PreviewViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll_preview_option, parent, false)) + } + + override fun getItemCount() = options.size + + override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { + val textView = holder.itemView as TextView + + val iconId = if (multiple) { + R.drawable.ic_check_box_outline_blank_18dp + } else { + R.drawable.ic_radio_button_unchecked_18dp + } + + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(textView, iconId, 0, 0, 0) + + textView.text = options[position] + + textView.setOnClickListener(clickListener) + } + +} + + +class PreviewViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SavedTootAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SavedTootAdapter.java new file mode 100644 index 0000000..6d4889c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SavedTootAdapter.java @@ -0,0 +1,122 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.db.TootEntity; + +import java.util.ArrayList; +import java.util.List; + +public class SavedTootAdapter extends RecyclerView.Adapter { + private List list; + private SavedTootAction handler; + + public SavedTootAdapter(Context context) { + super(); + list = new ArrayList<>(); + handler = (SavedTootAction) context; + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_saved_toot, parent, false); + return new TootViewHolder(view); + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + TootViewHolder holder = (TootViewHolder) viewHolder; + holder.bind(getItem(position)); + } + + @Override + public int getItemCount() { + return list.size(); + } + + public void setItems(List newToot) { + list = new ArrayList<>(); + list.addAll(newToot); + } + + public void addItems(List newToot) { + int end = list.size(); + list.addAll(newToot); + notifyItemRangeInserted(end, newToot.size()); + } + + @Nullable + public TootEntity removeItem(int position) { + if (position < 0 || position >= list.size()) { + return null; + } + TootEntity toot = list.remove(position); + notifyItemRemoved(position); + return toot; + } + + private TootEntity getItem(int position) { + if (position >= 0 && position < list.size()) { + return list.get(position); + } + return null; + } + + // handler saved toot + public interface SavedTootAction { + void delete(int position, TootEntity item); + + void click(int position, TootEntity item); + } + + private class TootViewHolder extends RecyclerView.ViewHolder { + View view; + TextView content; + ImageButton suppr; + + TootViewHolder(View view) { + super(view); + this.view = view; + this.content = view.findViewById(R.id.content); + this.suppr = view.findViewById(R.id.suppr); + } + + void bind(final TootEntity item) { + suppr.setEnabled(true); + + if (item != null) { + content.setText(item.getText()); + + suppr.setOnClickListener(v -> { + v.setEnabled(false); + handler.delete(getAdapterPosition(), item); + }); + view.setOnClickListener(v -> handler.click(getAdapterPosition(), item)); + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SingleViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SingleViewHolder.java new file mode 100644 index 0000000..26dfbb6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SingleViewHolder.java @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.adapter; + +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; + +// empty class to be able to instantiate ViewHolder which is abstract for dumbass reason +public class SingleViewHolder extends RecyclerView.ViewHolder { + public SingleViewHolder(View view) { + super(view); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java new file mode 100644 index 0000000..16460ca --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -0,0 +1,1166 @@ +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.MotionEvent; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import android.util.Log; +import android.graphics.Paint; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.text.HtmlCompat; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; +import com.google.android.material.button.MaterialButton; +import com.google.android.flexbox.FlexboxLayoutManager; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Attachment.Focus; +import com.keylesspalace.tusky.entity.Attachment.MetaData; +import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.EmojiReaction; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.*; +import com.keylesspalace.tusky.view.MediaPreviewImageView; +import com.keylesspalace.tusky.view.EmojiKeyboard; +import com.keylesspalace.tusky.viewdata.PollOptionViewData; +import com.keylesspalace.tusky.viewdata.PollViewData; +import com.keylesspalace.tusky.viewdata.PollViewDataKt; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.text.NumberFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import at.connyduck.sparkbutton.SparkButton; +import at.connyduck.sparkbutton.helpers.Utils; +import kotlin.collections.CollectionsKt; + +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + +public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { + public static class Key { + public static final String KEY_CREATED = "created"; + } + + private TextView displayName; + private TextView username; + private TextView replyInfo; + private ImageButton replyButton; + private SparkButton reblogButton; + private SparkButton favouriteButton; + private SparkButton bookmarkButton; + private ImageButton reactButton; + private ImageButton moreButton; + protected MediaPreviewImageView[] mediaPreviews; + private ImageView[] mediaOverlays; + private TextView sensitiveMediaWarning; + private View sensitiveMediaShow; + protected TextView[] mediaLabels; + protected CharSequence[] mediaDescriptions; + private MaterialButton contentWarningButton; + private ImageView avatarInset; + + public ImageView avatar; + public TextView timestampInfo; + public TextView content; + public TextView contentWarningDescription; + + private RecyclerView pollOptions; + private TextView pollDescription; + private Button pollButton; + + private LinearLayout cardView; + private LinearLayout cardInfo; + private ImageView cardImage; + private TextView cardTitle; + private TextView cardDescription; + private TextView cardUrl; + private PollAdapter pollAdapter; + + private SimpleDateFormat shortSdf; + private SimpleDateFormat longSdf; + + private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); + + protected int avatarRadius48dp; + private int avatarRadius36dp; + private int avatarRadius24dp; + + private final Drawable mediaPreviewUnloaded; + + private RecyclerView emojiReactionsView; + + protected StatusBaseViewHolder(View itemView) { + super(itemView); + displayName = itemView.findViewById(R.id.status_display_name); + username = itemView.findViewById(R.id.status_username); + timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + content = itemView.findViewById(R.id.status_content); + avatar = itemView.findViewById(R.id.status_avatar); + replyInfo = itemView.findViewById(R.id.reply_info); + replyButton = itemView.findViewById(R.id.status_reply); + reblogButton = itemView.findViewById(R.id.status_inset); + favouriteButton = itemView.findViewById(R.id.status_favourite); + bookmarkButton = itemView.findViewById(R.id.status_bookmark); + moreButton = itemView.findViewById(R.id.status_more); + reactButton = itemView.findViewById(R.id.status_emoji_react); + emojiReactionsView = itemView.findViewById(R.id.status_emoji_reactions); + + /* Disabled, because it doesn't handle parent resizes. It must be fixed and can be enabled again */ + /* float INCREASE_HORIZONTAL_HIT_AREA = 20.0f; + + ViewExtensionsKt.increaseHitArea(replyButton, 0.0f, INCREASE_HORIZONTAL_HIT_AREA); + if(reblogButton != null) + ViewExtensionsKt.increaseHitArea(reblogButton, 0.0f, INCREASE_HORIZONTAL_HIT_AREA); + ViewExtensionsKt.increaseHitArea(favouriteButton, 0.0f, INCREASE_HORIZONTAL_HIT_AREA); + ViewExtensionsKt.increaseHitArea(bookmarkButton, 0.0f, INCREASE_HORIZONTAL_HIT_AREA); + ViewExtensionsKt.increaseHitArea(moreButton, 0.0f, INCREASE_HORIZONTAL_HIT_AREA); */ + + itemView.findViewById(R.id.status_media_preview_container).setClipToOutline(true); + + mediaPreviews = new MediaPreviewImageView[]{ + itemView.findViewById(R.id.status_media_preview_0), + itemView.findViewById(R.id.status_media_preview_1), + itemView.findViewById(R.id.status_media_preview_2), + itemView.findViewById(R.id.status_media_preview_3) + }; + mediaOverlays = new ImageView[]{ + itemView.findViewById(R.id.status_media_overlay_0), + itemView.findViewById(R.id.status_media_overlay_1), + itemView.findViewById(R.id.status_media_overlay_2), + itemView.findViewById(R.id.status_media_overlay_3) + }; + sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning); + sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button); + mediaLabels = new TextView[]{ + itemView.findViewById(R.id.status_media_label_0), + itemView.findViewById(R.id.status_media_label_1), + itemView.findViewById(R.id.status_media_label_2), + itemView.findViewById(R.id.status_media_label_3) + }; + mediaDescriptions = new CharSequence[mediaLabels.length]; + contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description); + contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); + avatarInset = itemView.findViewById(R.id.status_avatar_inset); + + pollOptions = itemView.findViewById(R.id.status_poll_options); + pollDescription = itemView.findViewById(R.id.status_poll_description); + pollButton = itemView.findViewById(R.id.status_poll_button); + + cardView = itemView.findViewById(R.id.status_card_view); + cardInfo = itemView.findViewById(R.id.card_info); + cardImage = itemView.findViewById(R.id.card_image); + cardTitle = itemView.findViewById(R.id.card_title); + cardDescription = itemView.findViewById(R.id.card_description); + cardUrl = itemView.findViewById(R.id.card_link); + + pollAdapter = new PollAdapter(); + pollOptions.setAdapter(pollAdapter); + pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); + ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); + + this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); + this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); + + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); + this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); + this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); + + mediaPreviewUnloaded = new ColorDrawable(ThemeUtils.getColor(itemView.getContext(), R.attr.colorBackgroundAccent)); + } + + protected abstract int getMediaPreviewHeight(Context context); + + protected void setDisplayName(String name, List customEmojis) { + CharSequence emojifiedName = CustomEmojiHelper.emojify(name, customEmojis, displayName, true); + displayName.setText(emojifiedName); + } + + protected void setUsername(String name) { + Context context = username.getContext(); + String usernameText = context.getString(R.string.status_username_format, name); + username.setText(usernameText); + } + + public void toggleContentWarning() { + contentWarningButton.performClick(); + } + + protected void setSpoilerAndContent(boolean expanded, + @NonNull Spanned content, + @Nullable String spoilerText, + @Nullable Status.Mention[] mentions, + @NonNull List emojis, + @Nullable PollViewData poll, + @NonNull StatusDisplayOptions statusDisplayOptions, + final StatusActionListener listener) { + boolean sensitive = !TextUtils.isEmpty(spoilerText); + if (sensitive) { + CharSequence emojiSpoiler = CustomEmojiHelper.emojify(spoilerText, emojis, contentWarningDescription); + contentWarningDescription.setText(emojiSpoiler); + contentWarningDescription.setVisibility(View.VISIBLE); + contentWarningButton.setVisibility(View.VISIBLE); + setContentWarningButtonText(expanded); + contentWarningButton.setOnClickListener(view -> { + contentWarningDescription.invalidate(); + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onExpandedChange(!expanded, getAdapterPosition()); + } + setContentWarningButtonText(!expanded); + + this.setTextVisible(sensitive, !expanded, content, mentions, emojis, poll, statusDisplayOptions, listener); + }); + this.setTextVisible(sensitive, expanded, content, mentions, emojis, poll, statusDisplayOptions, listener); + } else { + contentWarningDescription.setVisibility(View.GONE); + contentWarningButton.setVisibility(View.GONE); + this.setTextVisible(sensitive, true, content, mentions, emojis, poll, statusDisplayOptions, listener); + } + } + + private void setContentWarningButtonText(boolean expanded) { + if (expanded) { + contentWarningButton.setText(R.string.status_content_warning_show_less); + } else { + contentWarningButton.setText(R.string.status_content_warning_show_more); + } + } + + private void setTextVisible(boolean sensitive, + boolean expanded, + Spanned content, + Status.Mention[] mentions, + List emojis, + @Nullable PollViewData poll, + StatusDisplayOptions statusDisplayOptions, + final StatusActionListener listener) { + if (expanded) { + CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content); + LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); + for (int i = 0; i < mediaLabels.length; ++i) { + updateMediaLabel(i, sensitive, expanded); + } + if (poll != null) { + setupPoll(poll, emojis, statusDisplayOptions, listener); + } else { + hidePoll(); + } + } else { + hidePoll(); + LinkHelper.setClickableMentions(this.content, mentions, listener); + } + if (TextUtils.isEmpty(this.content.getText())) { + this.content.setVisibility(View.GONE); + } else { + this.content.setVisibility(View.VISIBLE); + } + } + + private void hidePoll() { + pollButton.setVisibility(View.GONE); + pollDescription.setVisibility(View.GONE); + pollOptions.setVisibility(View.GONE); + } + + private void setAvatar(String url, + @Nullable String rebloggedUrl, + boolean isBot, + StatusDisplayOptions statusDisplayOptions) { + + int avatarRadius; + if (TextUtils.isEmpty(rebloggedUrl)) { + avatar.setPaddingRelative(0, 0, 0, 0); + + if (statusDisplayOptions.showBotOverlay() && isBot) { + avatarInset.setVisibility(View.VISIBLE); + avatarInset.setBackgroundColor(0x50ffffff); + Glide.with(avatarInset) + .load(R.drawable.ic_bot_24dp) + .into(avatarInset); + + } else { + avatarInset.setVisibility(View.GONE); + } + + avatarRadius = avatarRadius48dp; + + } else { + int padding = Utils.dpToPx(avatar.getContext(), 12); + avatar.setPaddingRelative(0, 0, padding, padding); + + avatarInset.setVisibility(View.VISIBLE); + avatarInset.setBackground(null); + ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, + statusDisplayOptions.animateAvatars()); + + avatarRadius = avatarRadius36dp; + } + + ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, + statusDisplayOptions.animateAvatars()); + + } + + protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { + if (statusDisplayOptions.useAbsoluteTime()) { + timestampInfo.setText(getAbsoluteTime(createdAt)); + } else { + if (createdAt == null) { + timestampInfo.setText("?m"); + } else { + long then = createdAt.getTime(); + long now = System.currentTimeMillis(); + String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + timestampInfo.setText(readout); + } + } + } + + private String getAbsoluteTime(Date createdAt) { + if (createdAt == null) { + return "??:??:??"; + } + if (DateUtils.isToday(createdAt.getTime())) { + return shortSdf.format(createdAt); + } else { + return longSdf.format(createdAt); + } + } + + private CharSequence getCreatedAtDescription(Date createdAt, + StatusDisplayOptions statusDisplayOptions) { + if (statusDisplayOptions.useAbsoluteTime()) { + return getAbsoluteTime(createdAt); + } else { + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + + if (createdAt == null) { + return "? minutes"; + } else { + long then = createdAt.getTime(); + long now = System.currentTimeMillis(); + return DateUtils.getRelativeTimeSpanString(then, now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE); + } + } + } + + protected void setIsReply(boolean isReply) { + if (isReply) { + replyButton.setImageResource(R.drawable.ic_reply_all_24dp); + } else { + replyButton.setImageResource(R.drawable.ic_reply_24dp); + } + + } + + protected void setReplyInfo(StatusViewData.Concrete status, StatusActionListener listener) { + if (status.getInReplyToId() != null) { + Context context = replyInfo.getContext(); + String replyToAccount = status.getInReplyToAccountAcct(); + replyInfo.setText(context.getString(R.string.status_replied_to_format, replyToAccount)); + if (!status.getParentVisible()) { + replyInfo.setPaintFlags(replyInfo.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + replyInfo.setOnClickListener(null); + } else { + replyInfo.setPaintFlags(replyInfo.getPaintFlags() & (~Paint.STRIKE_THRU_TEXT_FLAG)); + replyInfo.setOnClickListener(v -> listener.onViewReplyTo(getAdapterPosition())); + } + replyInfo.setVisibility(View.VISIBLE); + } else { + replyInfo.setVisibility(View.GONE); + } + } + + private void setReblogged(boolean reblogged) { + reblogButton.setChecked(reblogged); + } + + // This should only be called after setReblogged, in order to override the tint correctly. + private void setRebloggingEnabled(boolean enabled, Status.Visibility visibility) { + reblogButton.setEnabled(enabled && visibility != Status.Visibility.PRIVATE); + + if (enabled) { + int inactiveId; + int activeId; + if (visibility == Status.Visibility.PRIVATE) { + inactiveId = R.drawable.ic_reblog_private_24dp; + activeId = R.drawable.ic_reblog_private_active_24dp; + } else { + inactiveId = R.drawable.ic_reblog_24dp; + activeId = R.drawable.ic_reblog_active_24dp; + } + reblogButton.setInactiveImage(inactiveId); + reblogButton.setActiveImage(activeId); + } else { + int disabledId; + if (visibility == Status.Visibility.DIRECT) { + disabledId = R.drawable.ic_reblog_direct_24dp; + } else { + disabledId = R.drawable.ic_reblog_private_24dp; + } + reblogButton.setInactiveImage(disabledId); + reblogButton.setActiveImage(disabledId); + } + } + + protected void setFavourited(boolean favourited) { + favouriteButton.setChecked(favourited); + } + + protected void setBookmarked(boolean bookmarked) { + bookmarkButton.setChecked(bookmarked); + } + + private BitmapDrawable decodeBlurHash(String blurhash) { + return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash); + } + + private void loadImage(MediaPreviewImageView imageView, + @Nullable String previewUrl, + @Nullable MetaData meta, + @Nullable String blurhash) { + + Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; + + if (TextUtils.isEmpty(previewUrl)) { + imageView.removeFocalPoint(); + + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView); + + } else { + Focus focus = meta != null ? meta.getFocus() : null; + + if (focus != null) { // If there is a focal point for this attachment: + imageView.setFocalPoint(focus); + + Glide.with(imageView) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView); + } else { + imageView.removeFocalPoint(); + + Glide.with(imageView) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView); + } + } + } + + protected void setMediaPreviews(final List attachments, boolean sensitive, + final StatusActionListener listener, boolean showingContent, + boolean useBlurhash) { + Context context = itemView.getContext(); + final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS); + + + final int mediaPreviewHeight = getMediaPreviewHeight(context); + + if (n <= 2) { + mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight * 2; + mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight * 2; + } else { + mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight; + mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight; + mediaPreviews[2].getLayoutParams().height = mediaPreviewHeight; + mediaPreviews[3].getLayoutParams().height = mediaPreviewHeight; + } + + for (int i = 0; i < n; i++) { + Attachment attachment = attachments.get(i); + String previewUrl = attachment.getPreviewUrl(); + String description = attachment.getDescription(); + MediaPreviewImageView imageView = mediaPreviews[i]; + + imageView.setVisibility(View.VISIBLE); + + if (TextUtils.isEmpty(description)) { + imageView.setContentDescription(imageView.getContext() + .getString(R.string.action_view_media)); + } else { + imageView.setContentDescription(description); + } + + loadImage( + imageView, + showingContent ? previewUrl : null, + attachment.getMeta(), + useBlurhash ? attachment.getBlurhash() : null + ); + + final Attachment.Type type = attachment.getType(); + if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { + mediaOverlays[i].setVisibility(View.VISIBLE); + } else { + mediaOverlays[i].setVisibility(View.GONE); + } + + setAttachmentClickListener(imageView, listener, i, attachment, true); + } + + if (sensitive) { + sensitiveMediaWarning.setText(R.string.status_sensitive_media_title); + } else { + sensitiveMediaWarning.setText(R.string.status_media_hidden_title); + } + + sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); + sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); + sensitiveMediaShow.setOnClickListener(v -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(false, getAdapterPosition()); + } + v.setVisibility(View.GONE); + sensitiveMediaWarning.setVisibility(View.VISIBLE); + }); + sensitiveMediaWarning.setOnClickListener(v -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(true, getAdapterPosition()); + } + v.setVisibility(View.GONE); + sensitiveMediaShow.setVisibility(View.VISIBLE); + }); + + + // Hide any of the placeholder previews beyond the ones set. + for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) { + mediaPreviews[i].setVisibility(View.GONE); + } + } + + @DrawableRes + private static int getLabelIcon(Attachment.Type type) { + switch (type) { + case IMAGE: + return R.drawable.ic_photo_24dp; + case GIFV: + case VIDEO: + return R.drawable.ic_videocam_24dp; + case AUDIO: + return R.drawable.ic_music_box_24dp; + default: + return R.drawable.ic_attach_file_24dp; + } + } + + private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) { + Context context = itemView.getContext(); + CharSequence label = (sensitive && !showingContent) ? + context.getString(R.string.status_sensitive_media_title) : + mediaDescriptions[index]; + mediaLabels[index].setText(label); + } + + protected void setMediaLabel(List attachments, boolean sensitive, + final StatusActionListener listener, boolean showingContent) { + Context context = itemView.getContext(); + for (int i = 0; i < mediaLabels.length; i++) { + TextView mediaLabel = mediaLabels[i]; + if (i < attachments.size()) { + Attachment attachment = attachments.get(i); + mediaLabel.setVisibility(View.VISIBLE); + mediaDescriptions[i] = getAttachmentDescription(context, attachment); + updateMediaLabel(i, sensitive, showingContent); + + // Set the icon next to the label. + int drawableId = getLabelIcon(attachments.get(0).getType()); + mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0); + + setAttachmentClickListener(mediaLabel, listener, i, attachment, false); + } else { + mediaLabel.setVisibility(View.GONE); + } + } + } + + private void setAttachmentClickListener(View view, StatusActionListener listener, + int index, Attachment attachment, boolean animateTransition) { + view.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + if (sensitiveMediaWarning.getVisibility() == View.VISIBLE) { + listener.onContentHiddenChange(true, getAdapterPosition()); + } else { + listener.onViewMedia(position, index, animateTransition ? v : null); + } + } + }); + view.setOnLongClickListener(v -> { + CharSequence description = getAttachmentDescription(view.getContext(), attachment); + Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show(); + return true; + }); + } + + private static CharSequence getAttachmentDescription(Context context, Attachment attachment) { + String duration = ""; + if (attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) { + duration = formatDuration(attachment.getMeta().getDuration()) + " "; + } + if (TextUtils.isEmpty(attachment.getDescription())) { + return duration + context.getString(R.string.description_status_media_no_description_placeholder); + } else { + return duration + attachment.getDescription(); + } + } + + protected void hideSensitiveMediaWarning() { + sensitiveMediaWarning.setVisibility(View.GONE); + sensitiveMediaShow.setVisibility(View.GONE); + } + + protected void setupButtons(final StatusActionListener listener, + final String accountId, + final String statusContent, + StatusDisplayOptions statusDisplayOptions) { + View.OnClickListener profileButtonClickListener = button -> { + listener.onViewAccount(accountId); + }; + + avatar.setOnClickListener(profileButtonClickListener); + displayName.setOnClickListener(profileButtonClickListener); + + replyButton.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onReply(position); + } + }); + if (reblogButton != null) { + reblogButton.setEventListener((button, buttonState) -> { + // return true to play animaion + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + if (statusDisplayOptions.confirmReblogs()) { + showConfirmReblogDialog(listener, statusContent, buttonState, position); + return false; + } else { + listener.onReblog(!buttonState, position); + return true; + } + } else { + return false; + } + }); + } + + favouriteButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onFavourite(!buttonState, position); + } + return true; + }); + + bookmarkButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onBookmark(!buttonState, position); + } + return true; + }); + + moreButton.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onMore(v, position); + } + }); + + /* Even though the content TextView is a child of the container, it won't respond to clicks + * if it contains URLSpans without also setting its listener. The surrounding spans will + * just eat the clicks instead of deferring to the parent listener, but WILL respond to a + * listener directly on the TextView, for whatever reason. */ + View.OnClickListener viewThreadListener = v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onViewThread(position); + } + }; + content.setOnClickListener(viewThreadListener); + itemView.setOnClickListener(viewThreadListener); + } + + private void showConfirmReblogDialog(StatusActionListener listener, + String statusContent, + boolean buttonState, + int position) { + int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog; + new AlertDialog.Builder(reblogButton.getContext()) + .setMessage(statusContent) + .setPositiveButton(okButtonTextId, (__, ___) -> { + listener.onReblog(!buttonState, position); + if (!buttonState) { + // Play animation only when it's reblog, not unreblog + reblogButton.playAnimation(); + } + }) + .show(); + } + + private void setEmojiReactions(@Nullable List reactions, final StatusActionListener listener, final String statusId) { + if(reactButton != null) { + reactButton.setOnClickListener(v -> { + EmojiKeyboard.show(reactButton.getContext(), statusId, EmojiKeyboard.UNICODE_MODE, (id, emoji) -> { + listener.onEmojiReact(true, emoji, id); + }); + }); + } + + if(emojiReactionsView != null ) { + if(reactions != null && reactions.size() > 0) { + emojiReactionsView.setVisibility(View.VISIBLE); + FlexboxLayoutManager lm = new FlexboxLayoutManager(emojiReactionsView.getContext()); + emojiReactionsView.setLayoutManager(lm); + emojiReactionsView.setAdapter(new EmojiReactionsAdapter(reactions, listener, statusId)); + emojiReactionsView.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if(event.getAction() == MotionEvent.ACTION_POINTER_UP || + event.getAction() == MotionEvent.ACTION_UP) { + int position = getAdapterPosition(); + if(position != RecyclerView.NO_POSITION) + listener.onViewThread(position); + } + return false; + } + }); + } else { + emojiReactionsView.setVisibility(View.GONE); + emojiReactionsView.setLayoutManager(null); + emojiReactionsView.setAdapter(null); + } + } + } + + public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions) { + this.setupWithStatus(status, listener, statusDisplayOptions, null); + } + + protected void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { + if (payloads == null) { + setDisplayName(status.getUserFullName(), status.getAccountEmojis()); + setUsername(status.getNickname()); + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setIsReply(status.getInReplyToId() != null); + setReplyInfo(status, listener); + setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); + setReblogged(status.isReblogged()); + setFavourited(status.isFavourited()); + setBookmarked(status.isBookmarked()); + List attachments = status.getAttachments(); + boolean sensitive = status.isSensitive(); + if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); + + if (attachments.size() == 0) { + hideSensitiveMediaWarning(); + } + // Hide the unused label. + for (TextView mediaLabel : mediaLabels) { + mediaLabel.setVisibility(View.GONE); + } + } else { + setMediaLabel(attachments, sensitive, listener, status.isShowingContent()); + // Hide all unused views. + mediaPreviews[0].setVisibility(View.GONE); + mediaPreviews[1].setVisibility(View.GONE); + mediaPreviews[2].setVisibility(View.GONE); + mediaPreviews[3].setVisibility(View.GONE); + hideSensitiveMediaWarning(); + } + + if (cardView != null) { + setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions); + } + + setupButtons(listener, status.getSenderId(), status.getContent().toString(), + statusDisplayOptions); + setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); + + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), statusDisplayOptions, listener); + + setDescriptionForStatus(status, statusDisplayOptions); + + setEmojiReactions(status.getEmojiReactions(), listener, status.getId()); + + // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 + // RecyclerView tries to set AccessibilityDelegateCompat to null + // but ViewCompat code replaces is with the default one. RecyclerView never + // fetches another one from its delegate because it checks that it's set so we remove it + // and let RecyclerView ask for a new delegate. + itemView.setAccessibilityDelegate(null); + } else { + if (payloads instanceof List) + for (Object item : (List) payloads) { + if (Key.KEY_CREATED.equals(item)) { + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + } + } + + } + } + + protected static boolean hasPreviewableAttachment(List attachments) { + for (Attachment attachment : attachments) { + if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { + return false; + } + } + return true; + } + + private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, + StatusDisplayOptions statusDisplayOptions) { + Context context = itemView.getContext(); + + String description = context.getString(R.string.description_status, + status.getUserFullName(), + getContentWarningDescription(context, status), + (TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""), + getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions), + getReblogDescription(context, status), + status.getNickname(), + status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "", + status.isFavourited() ? context.getString(R.string.description_status_favourited) : "", + status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", + getMediaDescription(context, status), + getVisibilityDescription(context, status.getVisibility()), + getFavsText(context, status.getFavouritesCount()), + getReblogsText(context, status.getReblogsCount()), + getPollDescription(status, context, statusDisplayOptions) + ); + itemView.setContentDescription(description); + } + + private static CharSequence getReblogDescription(Context context, + @NonNull StatusViewData.Concrete status) { + String rebloggedUsername = status.getRebloggedByUsername(); + if (rebloggedUsername != null) { + return context + .getString(R.string.status_boosted_format, rebloggedUsername); + } else { + return ""; + } + } + + private static CharSequence getMediaDescription(Context context, + @NonNull StatusViewData.Concrete status) { + if (status.getAttachments().isEmpty()) { + return ""; + } + StringBuilder mediaDescriptions = CollectionsKt.fold( + status.getAttachments(), + new StringBuilder(), + (builder, a) -> { + if (a.getDescription() == null) { + String placeholder = + context.getString(R.string.description_status_media_no_description_placeholder); + return builder.append(placeholder); + } else { + builder.append("; "); + return builder.append(a.getDescription()); + } + }); + return context.getString(R.string.description_status_media, mediaDescriptions); + } + + private static CharSequence getContentWarningDescription(Context context, + @NonNull StatusViewData.Concrete status) { + if (!TextUtils.isEmpty(status.getSpoilerText())) { + return context.getString(R.string.description_status_cw, status.getSpoilerText()); + } else { + return ""; + } + } + + private static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { + + if (visibility == null) { + return ""; + } + + int resource; + switch (visibility) { + case PUBLIC: + resource = R.string.description_visiblity_public; + break; + case UNLISTED: + resource = R.string.description_visiblity_unlisted; + break; + case PRIVATE: + resource = R.string.description_visiblity_private; + break; + case DIRECT: + resource = R.string.description_visiblity_direct; + break; + default: + return ""; + } + return context.getString(resource); + } + + private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, + Context context, + StatusDisplayOptions statusDisplayOptions) { + PollViewData poll = status.getPoll(); + if (poll == null) { + return ""; + } else { + Object[] args = new CharSequence[5]; + List options = poll.getOptions(); + for (int i = 0; i < args.length; i++) { + if (i < options.size()) { + int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount()); + args[i] = buildDescription(options.get(i).getTitle(), percent, context); + } else { + args[i] = ""; + } + } + args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions, + context); + return context.getString(R.string.description_poll, args); + } + } + + protected CharSequence getFavsText(Context context, int count) { + if (count > 0) { + String countString = numberFormat.format(count); + return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); + } else { + return ""; + } + } + + protected CharSequence getReblogsText(Context context, int count) { + if (count > 0) { + String countString = numberFormat.format(count); + return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); + } else { + return ""; + } + } + + private void setupPoll(PollViewData poll, List emojis, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener listener) { + long timestamp = System.currentTimeMillis(); + + boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); + + Context context = pollDescription.getContext(); + + pollOptions.setVisibility(View.VISIBLE); + + if (expired || poll.getVoted()) { + // no voting possible + View.OnClickListener viewThreadListener = v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onViewThread(position); + } + }; + pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, PollAdapter.RESULT, viewThreadListener); + + pollButton.setVisibility(View.GONE); + } else { + // voting possible + pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, null); + + pollButton.setVisibility(View.VISIBLE); + + pollButton.setOnClickListener(v -> { + + int position = getAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + + List pollResult = pollAdapter.getSelected(); + + if (!pollResult.isEmpty()) { + listener.onVoteInPoll(position, pollResult); + } + } + + }); + } + + pollDescription.setVisibility(View.VISIBLE); + pollDescription.setText(getPollInfoText(timestamp, poll, statusDisplayOptions, context)); + } + + private CharSequence getPollInfoText(long timestamp, PollViewData poll, + StatusDisplayOptions statusDisplayOptions, + Context context) { + + String votesText; + if(poll.getVotersCount() == null) { + String voters = numberFormat.format(poll.getVotesCount()); + votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); + } else { + String voters = numberFormat.format(poll.getVotersCount()); + votesText = context.getResources().getQuantityString(R.plurals.poll_info_people, poll.getVotersCount(), voters); + } + CharSequence pollDurationInfo; + if (poll.getExpired()) { + pollDurationInfo = context.getString(R.string.poll_info_closed); + } else if (poll.getExpiresAt() == null) { + return votesText; + } else { + if (statusDisplayOptions.useAbsoluteTime()) { + pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt())); + } else { + pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); + } + } + + return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo); + } + + protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) { + if (cardViewMode != CardViewMode.NONE && + status.getAttachments().size() == 0 && + status.getCard() != null && + !TextUtils.isEmpty(status.getCard().getUrl()) && + (!status.isCollapsible() || !status.isCollapsed())) { + final Card card = status.getCard(); + cardView.setVisibility(View.VISIBLE); + cardTitle.setText(card.getTitle()); + if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { + cardDescription.setVisibility(View.GONE); + } else { + cardDescription.setVisibility(View.VISIBLE); + if (TextUtils.isEmpty(card.getDescription())) { + cardDescription.setText(card.getAuthorName()); + } else { + cardDescription.setText(card.getDescription()); + } + } + + cardUrl.setText(card.getUrl()); + + // Statuses from other activitypub sources can be marked sensitive even if there's no media, + // so let's blur the preview in that case + // If media previews are disabled, show placeholder for cards as well + if (statusDisplayOptions.mediaPreviewEnabled() && !status.isSensitive() && !TextUtils.isEmpty(card.getImage())) { + + int topLeftRadius = 0; + int topRightRadius = 0; + int bottomRightRadius = 0; + int bottomLeftRadius = 0; + + int radius = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_radius); + + if (card.getWidth() > card.getHeight()) { + cardView.setOrientation(LinearLayout.VERTICAL); + + cardImage.getLayoutParams().height = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_vertical_height); + cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; + topLeftRadius = radius; + topRightRadius = radius; + } else { + cardView.setOrientation(LinearLayout.HORIZONTAL); + cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.getLayoutParams().width = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + topLeftRadius = radius; + bottomLeftRadius = radius; + } + + RequestBuilder builder = Glide.with(cardImage).load(card.getImage()); + if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { + builder = builder.placeholder(decodeBlurHash(card.getBlurhash())); + } + builder.transform( + new CenterCrop(), + new GranularRoundedCorners(topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius) + ) + .into(cardImage); + } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { + int radius = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_radius); + + cardView.setOrientation(LinearLayout.HORIZONTAL); + cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.getLayoutParams().width = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + Glide.with(cardImage).load(decodeBlurHash(card.getBlurhash())) + .transform( + new CenterCrop(), + new GranularRoundedCorners(radius, 0, 0, radius) + ) + .into(cardImage); + } else { + cardView.setOrientation(LinearLayout.HORIZONTAL); + cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.getLayoutParams().width = cardImage.getContext().getResources() + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + cardImage.setImageResource(R.drawable.card_image_placeholder); + } + + cardView.setOnClickListener(v -> LinkHelper.openLink(card.getUrl(), v.getContext())); + cardView.setClipToOutline(true); + } else { + cardView.setVisibility(View.GONE); + } + } + + private static String formatDuration(double durationInSeconds) { + int seconds = (int) Math.round(durationInSeconds) % 60; + int minutes = (int) durationInSeconds % 3600 / 60; + int hours = (int) durationInSeconds / 3600; + + return String.format("%d:%02d:%02d", hours, minutes, seconds); + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java new file mode 100644 index 0000000..77fc90e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -0,0 +1,193 @@ +package com.keylesspalace.tusky.adapter; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.emoji.widget.EmojiAppCompatButton; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.flexbox.FlexboxLayoutManager; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.EmojiReaction; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.text.DateFormat; +import java.util.List; +import java.util.Date; + +class StatusDetailedViewHolder extends StatusBaseViewHolder { + private TextView reblogs; + private TextView favourites; + private View infoDivider; + + StatusDetailedViewHolder(View view) { + super(view); + reblogs = view.findViewById(R.id.status_reblogs); + favourites = view.findViewById(R.id.status_favourites); + infoDivider = view.findViewById(R.id.status_info_divider); + } + + @Override + protected int getMediaPreviewHeight(Context context) { + return context.getResources().getDimensionPixelSize(R.dimen.status_detail_media_preview_height); + } + + @Override + protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { + if (createdAt == null) { + timestampInfo.setText(""); + } else { + DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); + timestampInfo.setText(dateFormat.format(createdAt)); + } + } + + private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { + + if (reblogCount > 0) { + reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount)); + reblogs.setVisibility(View.VISIBLE); + } else { + reblogs.setVisibility(View.GONE); + } + if (favCount > 0) { + favourites.setText(getFavsText(favourites.getContext(), favCount)); + favourites.setVisibility(View.VISIBLE); + } else { + favourites.setVisibility(View.GONE); + } + + if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) { + infoDivider.setVisibility(View.GONE); + } else { + infoDivider.setVisibility(View.VISIBLE); + } + + reblogs.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onShowReblogs(position); + } + }); + favourites.setOnClickListener(v -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onShowFavs(position); + } + }); + } + + private void setApplication(@Nullable Status.Application app) { + if (app != null) { + + timestampInfo.append(" • "); + + if (app.getWebsite() != null) { + CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); + timestampInfo.append(text); + timestampInfo.setMovementMethod(LinkMovementMethod.getInstance()); + } else { + timestampInfo.append(app.getName()); + } + } + } + + @Override + protected void setupWithStatus(final StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { + super.setupWithStatus(status, listener, statusDisplayOptions, payloads); + setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions); // Always show card for detailed status + if (payloads == null) { + + if (!statusDisplayOptions.hideStats()) { + setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); + } else { + hideQuantitativeStats(); + } + + setApplication(status.getApplication()); + View.OnLongClickListener longClickListener = view -> { + TextView textView = (TextView) view; + ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("toot", textView.getText()); + clipboard.setPrimaryClip(clip); + + Toast.makeText(view.getContext(), R.string.copy_to_clipboard_success, Toast.LENGTH_SHORT).show(); + + return true; + }; + + content.setOnLongClickListener(longClickListener); + contentWarningDescription.setOnLongClickListener(longClickListener); + setStatusVisibility(status.getVisibility()); + } + } + + private void setStatusVisibility(Status.Visibility visibility) { + + if (visibility == null) { + return; + } + + int visibilityIcon; + switch (visibility) { + case PUBLIC: + visibilityIcon = R.drawable.ic_public_24dp; + break; + case UNLISTED: + visibilityIcon = R.drawable.ic_lock_open_24dp; + break; + case PRIVATE: + visibilityIcon = R.drawable.ic_lock_outline_24dp; + break; + case DIRECT: + visibilityIcon = R.drawable.ic_email_24dp; + break; + default: + return; + } + + final Drawable visibilityDrawable = this.timestampInfo.getContext() + .getDrawable(visibilityIcon); + if (visibilityDrawable == null) { + return; + } + + final int size = (int) this.timestampInfo.getTextSize(); + visibilityDrawable.setBounds( + 0, + 0, + size, + size + ); + visibilityDrawable.setTint(this.timestampInfo.getCurrentTextColor()); + this.timestampInfo.setCompoundDrawables( + visibilityDrawable, + null, + null, + null + ); + } + + private void hideQuantitativeStats() { + reblogs.setVisibility(View.GONE); + favourites.setVisibility(View.GONE); + infoDivider.setVisibility(View.GONE); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java new file mode 100644 index 0000000..4043f90 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -0,0 +1,132 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import android.text.InputFilter; +import android.text.TextUtils; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import android.widget.ImageButton; + +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.StringUtils; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import at.connyduck.sparkbutton.helpers.Utils; + +public class StatusViewHolder extends StatusBaseViewHolder { + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + + private TextView statusInfo; + private Button contentCollapseButton; + private ImageButton toggleVisibility; + + public StatusViewHolder(View itemView) { + super(itemView); + statusInfo = itemView.findViewById(R.id.status_info); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); + toggleVisibility = itemView.findViewById(R.id.status_toggle_mute); + } + + @Override + protected int getMediaPreviewHeight(Context context) { + return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); + } + + @Override + protected void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { + if (payloads == null) { + + setupCollapsedState(status, listener); + + String rebloggedByDisplayName = status.getRebloggedByUsername(); + if (rebloggedByDisplayName == null) { + hideStatusInfo(); + } else { + setRebloggedByDisplayName(rebloggedByDisplayName, status); + statusInfo.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition())); + } + + if(status.isUserMuted() || status.isThreadMuted()) { + toggleVisibility.setVisibility(View.VISIBLE); + toggleVisibility.setOnClickListener(v -> listener.onMute(getAdapterPosition(), true)); + } else { + toggleVisibility.setVisibility(View.GONE); + } + + } + super.setupWithStatus(status, listener, statusDisplayOptions, payloads); + + } + + private void setRebloggedByDisplayName(final CharSequence name, final StatusViewData.Concrete status) { + Context context = statusInfo.getContext(); + CharSequence wrappedName = StringUtils.unicodeWrap(name); + CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); + CharSequence emojifiedText = CustomEmojiHelper.emojify(boostedText, status.getRebloggedByAccountEmojis(), statusInfo); + statusInfo.setText(emojifiedText); + statusInfo.setVisibility(View.VISIBLE); + } + + // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed + void setPollInfo(final boolean ownPoll) { + statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); + statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); + statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0); + statusInfo.setVisibility(View.VISIBLE); + } + + void hideStatusInfo() { + statusInfo.setVisibility(View.GONE); + } + + private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) { + /* input filter for TextViews have to be set before text */ + if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) { + contentCollapseButton.setOnClickListener(view -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) + listener.onContentCollapsedChange(!status.isCollapsed(), position); + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (status.isCollapsed()) { + contentCollapseButton.setText(R.string.status_content_warning_show_more); + content.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setText(R.string.status_content_warning_show_less); + content.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + content.setFilters(NO_INPUT_FILTER); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StickerAdapater.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/StickerAdapater.kt new file mode 100644 index 0000000..eec2952 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StickerAdapater.kt @@ -0,0 +1,117 @@ +package com.keylesspalace.tusky.adapter + +import android.graphics.drawable.Drawable +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.appcompat.widget.AppCompatImageButton +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.StickerPack +import com.keylesspalace.tusky.view.EmojiKeyboard +import com.keylesspalace.tusky.view.EmojiKeyboard.EmojiKeyboardAdapter +import java.util.* + +class StickerAdapter( + private val stickerPacks: Array, + private val listener: EmojiKeyboard.OnEmojiSelectedListener + ) : RecyclerView.Adapter(), TabConfigurationStrategy, EmojiKeyboardAdapter { + + private val recentsAdapter = StickerPageAdapter(null, listener, emptyList()) + // this value doesn't reflect actual button width but how much we want for button to take space + // this is bad, only villains do that + private val BUTTON_WIDTH_DP = 90.0f + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + if (position == 0) { + tab.setIcon(R.drawable.ic_access_time) + return + } + + val pack = stickerPacks[position - 1] + val imageView = ImageView(tab.view.context) + imageView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.MATCH_PARENT) + Glide.with(imageView) + .asDrawable() + .load(pack.internal_url + pack.tabIcon) + .thumbnail() + .centerCrop() + .into( object: CustomTarget() { + override fun onLoadCleared(placeholder: Drawable?) { + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + // tab.icon = resource + imageView.setImageDrawable(resource) + tab.customView = imageView + } + }) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SingleViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_emoji_keyboard_page, parent, false) + val holder = SingleViewHolder(view) + + val dm = parent.context.resources.displayMetrics + val wdp = dm.widthPixels / dm.density + val rows = (wdp / BUTTON_WIDTH_DP + 0.5).toInt() + + (view as RecyclerView).layoutManager = GridLayoutManager(view.getContext(), rows) + return holder + } + + override fun getItemCount(): Int { + return stickerPacks.size + 1 + } + + override fun onRecentsUpdate(set: MutableSet) { + val list = set.toMutableList() + list.reverse() + recentsAdapter.stickers = list + recentsAdapter.notifyDataSetChanged() + } + + override fun onBindViewHolder(holder: SingleViewHolder, position: Int) { + if( position == 0 ) { + (holder.itemView as RecyclerView).adapter = recentsAdapter + } else { + val pack = stickerPacks[position - 1] + (holder.itemView as RecyclerView).adapter = StickerPageAdapter(pack.internal_url, listener, pack.stickers) + } + } + + private class StickerPageAdapter( + private val url: String?, + var listener: EmojiKeyboard.OnEmojiSelectedListener, + var stickers: List + ) : RecyclerView.Adapter() { + override fun getItemCount(): Int { + return stickers.size + } + + override fun onBindViewHolder(holder: SingleViewHolder, position: Int) { + (holder.itemView as AppCompatImageButton).setOnClickListener { + listener.onEmojiSelected("", ( url ?: "" ) + stickers[position]) + } + Glide.with(holder.itemView) + .load(( url ?: "" ) + stickers[position]) + .thumbnail() + .into(holder.itemView) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SingleViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_emoji_keyboard_sticker, parent, false) + return SingleViewHolder(view) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt new file mode 100644 index 0000000..b4517dc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -0,0 +1,153 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.core.view.size +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.keylesspalace.tusky.HASHTAG +import com.keylesspalace.tusky.LIST +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.android.synthetic.main.item_tab_preference.view.* + +interface ItemInteractionListener { + fun onTabAdded(tab: TabData) + fun onTabRemoved(position: Int) + fun onStartDelete(viewHolder: RecyclerView.ViewHolder) + fun onStartDrag(viewHolder: RecyclerView.ViewHolder) + fun onActionChipClicked(tab: TabData, tabPosition: Int) + fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) +} + +class TabAdapter(private var data: List, + private val small: Boolean, + private val listener: ItemInteractionListener, + private var removeButtonEnabled: Boolean = false) : RecyclerView.Adapter() { + + fun updateData(newData: List) { + this.data = newData + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val layoutId = if (small) { + R.layout.item_tab_preference_small + } else { + R.layout.item_tab_preference + } + val view = LayoutInflater.from(parent.context).inflate(layoutId, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val context = holder.itemView.context + val tab = data[position] + if (!small && tab.id == LIST) { + holder.itemView.textView.text = tab.arguments.getOrNull(1).orEmpty() + } else { + holder.itemView.textView.setText(tab.text) + } + holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(tab.icon, 0, 0, 0) + if (small) { + holder.itemView.textView.setOnClickListener { + listener.onTabAdded(tab) + } + } + holder.itemView.imageView?.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + listener.onStartDrag(holder) + true + } else { + false + } + } + holder.itemView.removeButton?.setOnClickListener { + listener.onTabRemoved(holder.adapterPosition) + } + if (holder.itemView.removeButton != null) { + holder.itemView.removeButton.isEnabled = removeButtonEnabled + ThemeUtils.setDrawableTint( + holder.itemView.context, + holder.itemView.removeButton.drawable, + (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) + ) + } + + if (!small) { + + if (tab.id == HASHTAG) { + holder.itemView.chipGroup.show() + + /* + * The chip group will always contain the actionChip (it is defined in the xml layout). + * The other dynamic chips are inserted in front of the actionChip. + * This code tries to reuse already added chips to reduce the number of Views created. + */ + tab.arguments.forEachIndexed { i, arg -> + + val chip = holder.itemView.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip? + ?: Chip(context).apply { + holder.itemView.chipGroup.addView(this, holder.itemView.chipGroup.size - 1) + chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary)) + } + + chip.text = arg + + if(tab.arguments.size <= 1) { + chip.chipIcon = null + chip.setOnClickListener(null) + } else { + chip.setChipIconResource(R.drawable.ic_cancel_24dp) + chip.setOnClickListener { + listener.onChipClicked(tab, holder.adapterPosition, i) + } + } + } + + while(holder.itemView.chipGroup.size - 1 > tab.arguments.size) { + holder.itemView.chipGroup.removeViewAt(tab.arguments.size) + } + + holder.itemView.actionChip.setOnClickListener { + listener.onActionChipClicked(tab, holder.adapterPosition) + } + + } else { + holder.itemView.chipGroup.hide() + } + } + } + + override fun getItemCount() = data.size + + fun setRemoveButtonVisible(enabled: Boolean) { + if (removeButtonEnabled != enabled) { + removeButtonEnabled = enabled + notifyDataSetChanged() + } + } + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java new file mode 100644 index 0000000..0143cb4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java @@ -0,0 +1,164 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +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 com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.ArrayList; +import java.util.List; + +public class ThreadAdapter extends RecyclerView.Adapter { + private static final int VIEW_TYPE_STATUS = 0; + private static final int VIEW_TYPE_STATUS_DETAILED = 1; + + private List statuses; + private StatusDisplayOptions statusDisplayOptions; + private StatusActionListener statusActionListener; + private int detailedStatusPosition; + + public ThreadAdapter(StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + this.statusDisplayOptions = statusDisplayOptions; + this.statusActionListener = listener; + this.statuses = new ArrayList<>(); + detailedStatusPosition = RecyclerView.NO_POSITION; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_STATUS: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_status, parent, false); + return new StatusViewHolder(view); + } + case VIEW_TYPE_STATUS_DETAILED: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_status_detailed, parent, false); + return new StatusDetailedViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + StatusViewData.Concrete status = statuses.get(position); + if (position == detailedStatusPosition) { + StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder; + holder.setupWithStatus(status, statusActionListener, statusDisplayOptions); + } else { + StatusViewHolder holder = (StatusViewHolder) viewHolder; + holder.setupWithStatus(status, statusActionListener, statusDisplayOptions); + } + } + + @Override + public int getItemViewType(int position) { + if (position == detailedStatusPosition) { + return VIEW_TYPE_STATUS_DETAILED; + } else { + return VIEW_TYPE_STATUS; + } + } + + @Override + public int getItemCount() { + return statuses.size(); + } + + public void setStatuses(List statuses) { + this.statuses.clear(); + this.statuses.addAll(statuses); + notifyDataSetChanged(); + } + + public void addItem(int position, StatusViewData.Concrete statusViewData) { + statuses.add(position, statusViewData); + notifyItemInserted(position); + } + + public void clearItems() { + int oldSize = statuses.size(); + statuses.clear(); + detailedStatusPosition = RecyclerView.NO_POSITION; + notifyItemRangeRemoved(0, oldSize); + } + + public void addAll(int position, List statuses) { + this.statuses.addAll(position, statuses); + notifyItemRangeInserted(position, statuses.size()); + } + + public void addAll(List statuses) { + int end = statuses.size(); + this.statuses.addAll(statuses); + notifyItemRangeInserted(end, statuses.size()); + } + + public void removeItem(int position) { + statuses.remove(position); + notifyItemRemoved(position); + } + + public void clear() { + statuses.clear(); + detailedStatusPosition = RecyclerView.NO_POSITION; + notifyDataSetChanged(); + } + + public void setItem(int position, StatusViewData.Concrete status, boolean notifyAdapter) { + statuses.set(position, status); + if (notifyAdapter) { + notifyItemChanged(position); + } + } + + @Nullable + public StatusViewData.Concrete getItem(int position) { + if (position >= 0 && position < statuses.size()) { + return statuses.get(position); + } else { + return null; + } + } + + public void setDetailedStatusPosition(int position) { + if (position != detailedStatusPosition + && detailedStatusPosition != RecyclerView.NO_POSITION) { + int prior = detailedStatusPosition; + detailedStatusPosition = position; + notifyItemChanged(prior); + } else { + detailedStatusPosition = position; + } + } + + public int getDetailedStatusPosition() { + return detailedStatusPosition; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java new file mode 100644 index 0000000..f5ab042 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java @@ -0,0 +1,151 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +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 com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.List; + +public final class TimelineAdapter extends RecyclerView.Adapter { + + public interface AdapterDataSource { + int getItemCount(); + + T getItemAt(int pos); + } + + private static final int VIEW_TYPE_STATUS = 0; + private static final int VIEW_TYPE_STATUS_MUTED = 1; + private static final int VIEW_TYPE_PLACEHOLDER = 2; + + private final AdapterDataSource dataSource; + private StatusDisplayOptions statusDisplayOptions; + private final StatusActionListener statusListener; + + public TimelineAdapter(AdapterDataSource dataSource, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener statusListener) { + this.dataSource = dataSource; + this.statusDisplayOptions = statusDisplayOptions; + this.statusListener = statusListener; + } + + public boolean getMediaPreviewEnabled() { + return statusDisplayOptions.mediaPreviewEnabled(); + } + + public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { + this.statusDisplayOptions = statusDisplayOptions.copy( + statusDisplayOptions.animateAvatars(), + mediaPreviewEnabled, + statusDisplayOptions.useAbsoluteTime(), + statusDisplayOptions.showBotOverlay(), + statusDisplayOptions.useBlurhash(), + statusDisplayOptions.cardViewMode(), + statusDisplayOptions.confirmReblogs(), + statusDisplayOptions.renderStatusAsMention(), + statusDisplayOptions.hideStats() + ); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_STATUS: { + View view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_status, viewGroup, false); + return new StatusViewHolder(view); + } + case VIEW_TYPE_STATUS_MUTED: { + View view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_status_muted, viewGroup, false); + return new MutedStatusViewHolder(view); + } + case VIEW_TYPE_PLACEHOLDER: { + View view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_status_placeholder, viewGroup, false); + return new PlaceholderViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + bindViewHolder(viewHolder, position, null); + } + + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { + bindViewHolder(viewHolder, position, payloads); + } + + private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { + StatusViewData status = dataSource.getItemAt(position); + if (status instanceof StatusViewData.Placeholder) { + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(statusListener, ((StatusViewData.Placeholder) status).isLoading()); + } else if (status instanceof StatusViewData.Concrete) { + StatusViewData.Concrete concrete = (StatusViewData.Concrete)status; + if(concrete.isMuted()) { + MutedStatusViewHolder holder = (MutedStatusViewHolder) viewHolder; + holder.setupWithStatus(concrete, statusListener, statusDisplayOptions, + payloads != null && !payloads.isEmpty() ? payloads.get(0) : null); + } else { + StatusViewHolder holder = (StatusViewHolder) viewHolder; + holder.setupWithStatus(concrete, statusListener, statusDisplayOptions, + payloads != null && !payloads.isEmpty() ? payloads.get(0) : null); + } + } + } + + @Override + public int getItemCount() { + return dataSource.getItemCount(); + } + + @Override + public int getItemViewType(int position) { + if (dataSource.getItemAt(position) instanceof StatusViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + StatusViewData.Concrete concrete = (StatusViewData.Concrete)dataSource.getItemAt(position); + if(concrete.isMuted()) { + return VIEW_TYPE_STATUS_MUTED; + } else { + return VIEW_TYPE_STATUS; + } + } + } + + @Override + public long getItemId(int position) { + return dataSource.getItemAt(position).getViewDataId(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/UnicodeEmojiAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/UnicodeEmojiAdapter.java new file mode 100644 index 0000000..776fa14 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/UnicodeEmojiAdapter.java @@ -0,0 +1,129 @@ +package com.keylesspalace.tusky.adapter; + +import android.view.*; +import android.util.*; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; +import com.google.android.flexbox.FlexboxLayoutManager; +import androidx.viewpager2.widget.ViewPager2; +import androidx.recyclerview.widget.*; +import androidx.emoji.widget.EmojiAppCompatButton; +import androidx.emoji.text.EmojiCompat; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.view.EmojiKeyboard; +import com.keylesspalace.tusky.util.Emojis; +import java.util.*; + +public class UnicodeEmojiAdapter + extends RecyclerView.Adapter + implements TabLayoutMediator.TabConfigurationStrategy, EmojiKeyboard.EmojiKeyboardAdapter { + + private String id; + private List recents; + private EmojiKeyboard.OnEmojiSelectedListener listener; + private RecyclerView recentsView; + + private final static float BUTTON_WIDTH_DP = 65.0f; // empirically found value :( + + public UnicodeEmojiAdapter(String id, EmojiKeyboard.OnEmojiSelectedListener listener) { + super(); + this.id = id; + this.listener = listener; + } + + @Override + public void onConfigureTab(TabLayout.Tab tab, int position) { + if(position == 0) { + tab.setIcon(R.drawable.ic_access_time); + } else { + tab.setText(Emojis.EMOJIS[position - 1][0]); + } + } + + @Override + public int getItemCount() { + return Emojis.EMOJIS.length + 1; + } + + @Override + public void onBindViewHolder(SingleViewHolder holder, int position) { + if(position == 0) { + recentsView = ((RecyclerView)holder.itemView); + recentsView.setAdapter(new UnicodeEmojiPageAdapter(recents, id, listener)); + } else { + ((RecyclerView)holder.itemView).setAdapter( + new UnicodeEmojiPageAdapter(Arrays.asList(Emojis.EMOJIS[position - 1]), id, listener)); + } + } + + @Override + public SingleViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_emoji_keyboard_page, parent, false); + SingleViewHolder holder = new SingleViewHolder(view); + + DisplayMetrics dm = parent.getContext().getResources().getDisplayMetrics(); + float wdp = dm.widthPixels / dm.density; + int rows = (int) (wdp / BUTTON_WIDTH_DP + 0.5); + + ((RecyclerView)view).setLayoutManager(new GridLayoutManager(view.getContext(), rows)); + return holder; + } + + @Override + public void onRecentsUpdate(Set set) { + recents = new ArrayList(set); + Collections.reverse(recents); + if(recentsView != null) + recentsView.getAdapter().notifyDataSetChanged(); + } + + private abstract class UnicodeEmojiBasePageAdapter extends RecyclerView.Adapter { + private final EmojiKeyboard.OnEmojiSelectedListener listener; + private final String id; + + public UnicodeEmojiBasePageAdapter(String id, EmojiKeyboard.OnEmojiSelectedListener listener) { + this.id = id; + this.listener = listener; + } + + abstract public String getEmoji(int position); + + @Override + public void onBindViewHolder(SingleViewHolder holder, int position) { + String emoji = getEmoji(position); + EmojiAppCompatButton btn = (EmojiAppCompatButton)holder.itemView; + + btn.setText(emoji); + btn.setOnClickListener(v -> listener.onEmojiSelected(id, emoji)); + } + + @Override + public SingleViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_emoji_keyboard_emoji, parent, false); + return new SingleViewHolder(view); + } + } + + private class UnicodeEmojiPageAdapter extends UnicodeEmojiBasePageAdapter { + private final List emojis; + + public UnicodeEmojiPageAdapter(List emojis, String id, EmojiKeyboard.OnEmojiSelectedListener listener) { + super(id, listener); + this.emojis = emojis; + } + + @Override + public int getItemCount() { + return emojis.size(); + } + + @Override + public String getEmoji(int position) { + return emojis.get(position); + } + } + +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt new file mode 100644 index 0000000..2b3a9b7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -0,0 +1,59 @@ +package com.keylesspalace.tusky.appstore + +import com.google.gson.Gson +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import io.reactivex.Single +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class CacheUpdater @Inject constructor( + eventHub: EventHub, + accountManager: AccountManager, + private val appDatabase: AppDatabase, + gson: Gson +) { + + private val disposable: Disposable + + init { + val timelineDao = appDatabase.timelineDao() + disposable = eventHub.events.subscribe { event -> + val accountId = accountManager.activeAccount?.id ?: return@subscribe + when (event) { + is FavoriteEvent -> + timelineDao.setFavourited(accountId, event.statusId, event.favourite) + is ReblogEvent -> + timelineDao.setReblogged(accountId, event.statusId, event.reblog) + is BookmarkEvent -> + timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + is UnfollowEvent -> + timelineDao.removeAllByUser(accountId, event.accountId) + is StatusDeletedEvent -> + timelineDao.delete(accountId, event.statusId) + is EmojiReactEvent -> { + val pleromaString = gson.toJson(event.newStatus.pleroma) + timelineDao.setPleroma(accountId, event.newStatus.id, pleromaString) + } + is PollVoteEvent -> { + val pollString = gson.toJson(event.poll) + timelineDao.setVoted(accountId, event.statusId, pollString) + } + } + } + } + + fun stop() { + this.disposable.dispose() + } + + fun clearForUser(accountId: Long) { + Single.fromCallable { + appDatabase.timelineDao().removeAllForAccount(accountId) + appDatabase.timelineDao().removeAllUsersForAccount(accountId) + } + .subscribeOn(Schedulers.io()) + .subscribe() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt new file mode 100644 index 0000000..0f469d7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -0,0 +1,28 @@ +package com.keylesspalace.tusky.appstore + +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.ChatMessage +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status + +data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable +data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable +data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable +data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable +data class EmojiReactEvent(val newStatus: Status) : Dispatchable +data class UnfollowEvent(val accountId: String) : Dispatchable +data class BlockEvent(val accountId: String) : Dispatchable +data class MuteEvent(val accountId: String, val mute: Boolean) : Dispatchable +data class StatusDeletedEvent(val statusId: String) : Dispatchable +data class StatusPreviewEvent(val status: Status) : Dispatchable +data class StatusComposedEvent(val status: Status) : Dispatchable +data class StatusScheduledEvent(val status: Status) : Dispatchable +data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable +data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable +data class MainTabsChangedEvent(val newTabs: List) : Dispatchable +data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable +data class DomainMuteEvent(val instance: String): Dispatchable +data class ChatMessageDeliveredEvent(val chatMsg: ChatMessage) : Dispatchable +data class ChatMessageReceivedEvent(val chatMsg: ChatMessage) : Dispatchable +data class AnnouncementReadEvent(val announcementId: String): Dispatchable diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt new file mode 100644 index 0000000..ceaf513 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -0,0 +1,22 @@ +package com.keylesspalace.tusky.appstore + +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject + +interface Event +interface Dispatchable : Event + +interface EventHub { + val events: Observable + fun dispatch(event: Dispatchable) +} + +object EventHubImpl : EventHub { + + private val eventsSubject = PublishSubject.create() + override val events: Observable = eventsSubject + + override fun dispatch(event: Dispatchable) { + eventsSubject.onNext(event) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt new file mode 100644 index 0000000..c0e6bdd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -0,0 +1,126 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.announcements + +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.size +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Announcement +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.emojify +import kotlinx.android.synthetic.main.item_announcement.view.* + + +interface AnnouncementActionListener: LinkListener { + fun openReactionPicker(announcementId: String, target: View) + fun addReaction(announcementId: String, name: String) + fun removeReaction(announcementId: String, name: String) +} + +class AnnouncementAdapter( + private var items: List = emptyList(), + private val listener: AnnouncementActionListener, + private val wellbeingEnabled: Boolean = false +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnouncementViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_announcement, parent, false) + return AnnouncementViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: AnnouncementViewHolder, position: Int) { + viewHolder.bind(items[position]) + } + + override fun getItemCount() = items.size + + fun updateList(items: List) { + this.items = items + notifyDataSetChanged() + } + + inner class AnnouncementViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + private val text: TextView = view.text + private val chips: ChipGroup = view.chipGroup + private val addReactionChip: Chip = view.addReactionChip + + fun bind(item: Announcement) { + LinkHelper.setClickableText(text, item.content, null, listener) + + // If wellbeing mode is enabled, announcement badge counts should not be shown. + if (wellbeingEnabled) { + // Since reactions are not visible in wellbeing mode, + // we shouldn't be able to add any ourselves. + addReactionChip.visibility = View.GONE + return + } + + item.reactions.forEachIndexed { i, reaction -> + (chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? + ?: Chip(ContextThemeWrapper(view.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { + isCheckable = true + checkedIcon = null + chips.addView(this, i) + }) + .apply { + val emojiText = if (reaction.url == null) { + reaction.name + } else { + view.context.getString(R.string.emoji_shortcode_format, reaction.name) + } + text = ("$emojiText ${reaction.count}") + .emojify( + listOf(Emoji( + reaction.name, + reaction.url ?: "", + reaction.staticUrl ?: "", + null + )), + this + ) + + isChecked = reaction.me + + setOnClickListener { + if (reaction.me) { + listener.removeReaction(item.id, reaction.name) + } else { + listener.addReaction(item.id, reaction.name) + } + } + } + } + + while (chips.size - 1 > item.reactions.size) { + chips.removeViewAt(item.reactions.size) + } + + addReactionChip.setOnClickListener { + listener.openReactionPicker(item.id, it) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt new file mode 100644 index 0000000..0b96b43 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -0,0 +1,180 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.announcements + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.PopupWindow +import androidx.activity.viewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.adapter.EmojiAdapter +import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.EmojiPicker +import kotlinx.android.synthetic.main.activity_announcements.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory } + + private lateinit var adapter: AnnouncementAdapter + + private val picker by lazy { EmojiPicker(this) } + private val pickerDialog by lazy { + PopupWindow(this) + .apply { + contentView = picker + isFocusable = true + setOnDismissListener { + currentAnnouncementId = null + } + } + } + private var currentAnnouncementId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_announcements) + + setSupportActionBar(toolbar) + supportActionBar?.apply { + title = getString(R.string.title_announcements) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements) + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + announcementsList.setHasFixedSize(true) + announcementsList.layoutManager = LinearLayoutManager(this) + val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + announcementsList.addItemDecoration(divider) + + val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + + adapter = AnnouncementAdapter(emptyList(), this, wellbeingEnabled) + + announcementsList.adapter = adapter + + viewModel.announcements.observe(this) { + when (it) { + is Success -> { + progressBar.hide() + swipeRefreshLayout.isRefreshing = false + if (it.data.isNullOrEmpty()) { + errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements) + errorMessageView.show() + } else { + errorMessageView.hide() + } + adapter.updateList(it.data ?: listOf()) + } + is Loading -> { + errorMessageView.hide() + } + is Error -> { + progressBar.hide() + swipeRefreshLayout.isRefreshing = false + errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { + refreshAnnouncements() + } + errorMessageView.show() + } + } + } + + viewModel.emojis.observe(this) { + picker.adapter = EmojiAdapter(it, this) + } + + viewModel.load() + progressBar.show() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun refreshAnnouncements() { + viewModel.load() + swipeRefreshLayout.isRefreshing = true + } + + override fun openReactionPicker(announcementId: String, target: View) { + currentAnnouncementId = announcementId + pickerDialog.showAsDropDown(target) + } + + override fun onEmojiSelected(shortcode: String) { + viewModel.addReaction(currentAnnouncementId!!, shortcode) + pickerDialog.dismiss() + } + + override fun addReaction(announcementId: String, name: String) { + viewModel.addReaction(announcementId, name) + } + + override fun removeReaction(announcementId: String, name: String) { + viewModel.removeReaction(announcementId, name) + } + + override fun onViewTag(tag: String?) { + val intent = Intent(this, ViewTagActivity::class.java) + intent.putExtra("hashtag", tag) + startActivityWithSlideInAnimation(intent) + } + + override fun onViewAccount(id: String?) { + if (id != null) { + viewAccount(id) + } + } + + override fun onViewUrl(url: String?) { + if (url != null) { + viewUrl(url) + } + } + + companion object { + fun newIntent(context: Context) = Intent(context, AnnouncementsActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt new file mode 100644 index 0000000..b2aa778 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -0,0 +1,188 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.announcements + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.keylesspalace.tusky.appstore.AnnouncementReadEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.entity.Announcement +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.rxkotlin.Singles +import javax.inject.Inject + +class AnnouncementsViewModel @Inject constructor( + accountManager: AccountManager, + private val appDatabase: AppDatabase, + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) : RxAwareViewModel() { + + private val announcementsMutable = MutableLiveData>>() + val announcements: LiveData>> = announcementsMutable + + private val emojisMutable = MutableLiveData>() + val emojis: LiveData> = emojisMutable + + init { + Singles.zip( + mastodonApi.getCustomEmojis(), + appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + .map> { Either.Left(it) } + .onErrorResumeNext( + mastodonApi.getInstance() + .map { Either.Right(it) } + ) + ) { emojis, either -> + either.asLeftOrNull()?.copy(emojiList = emojis) + ?: InstanceEntity( + accountManager.activeAccount?.domain!!, + emojis, + either.asRight().maxTootChars, + either.asRight().pollLimits?.maxOptions, + either.asRight().pollLimits?.maxOptionChars, + either.asRight().version, + either.asRight().chatLimit + ) + } + .doOnSuccess { + appDatabase.instanceDao().insertOrReplace(it) + } + .subscribe({ + emojisMutable.postValue(it.emojiList) + }, { + Log.w(TAG, "Failed to get custom emojis.", it) + }) + .autoDispose() + } + + fun load() { + announcementsMutable.postValue(Loading()) + mastodonApi.listAnnouncements() + .subscribe({ + announcementsMutable.postValue(Success(it)) + it.filter { announcement -> !announcement.read } + .forEach { announcement -> + mastodonApi.dismissAnnouncement(announcement.id) + .subscribe( + { + eventHub.dispatch(AnnouncementReadEvent(announcement.id)) + }, + { throwable -> + Log.d(TAG, "Failed to mark announcement as read.", throwable) + } + ) + .autoDispose() + } + }, { + announcementsMutable.postValue(Error(cause = it)) + }) + .autoDispose() + } + + fun addReaction(announcementId: String, name: String) { + mastodonApi.addAnnouncementReaction(announcementId, name) + .subscribe({ + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { + announcement.reactions.map { reaction -> + if (reaction.name == name) { + reaction.copy( + count = reaction.count + 1, + me = true + ) + } else { + reaction + } + } + } else { + listOf( + *announcement.reactions.toTypedArray(), + emojis.value!!.find { emoji -> emoji.shortcode == name } + !!.run { + Announcement.Reaction( + name, + 1, + true, + url, + staticUrl + ) + } + ) + } + ) + } else { + announcement + } + } + ) + ) + }, { + Log.w(TAG, "Failed to add reaction to the announcement.", it) + }) + .autoDispose() + } + + fun removeReaction(announcementId: String, name: String) { + mastodonApi.removeAnnouncementReaction(announcementId, name) + .subscribe({ + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = announcement.reactions.mapNotNull { reaction -> + if (reaction.name == name) { + if (reaction.count > 1) { + reaction.copy( + count = reaction.count - 1, + me = false + ) + } else { + null + } + } else { + reaction + } + } + ) + } else { + announcement + } + } + ) + ) + }, { + Log.w(TAG, "Failed to remove reaction from the announcement.", it) + }) + .autoDispose() + } + + companion object { + private const val TAG = "AnnouncementsViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt new file mode 100644 index 0000000..c5ca662 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt @@ -0,0 +1,1125 @@ +package com.keylesspalace.tusky.components.chat + +import android.Manifest +import android.app.Activity +import android.app.ProgressDialog +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.util.Log +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.widget.ImageButton +import android.widget.PopupMenu +import android.widget.Toast +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Chat +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.interfaces.ChatActionListener +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.repository.ChatMesssageOrPlaceholder +import com.keylesspalace.tusky.repository.ChatRepository +import com.keylesspalace.tusky.viewdata.ChatMessageViewData +import androidx.arch.core.util.Function +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.core.view.inputmethod.InputContentInfoCompat +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.* +import com.bumptech.glide.Glide +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.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.adapter.* +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.common.* +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.repository.Placeholder +import com.keylesspalace.tusky.repository.TimelineRequestMode +import com.keylesspalace.tusky.service.MessageToSend +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.EmojiKeyboard +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import com.uber.autodispose.android.lifecycle.autoDispose +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.activity_chat.* +import kotlinx.android.synthetic.main.toolbar_basic.toolbar +import java.io.File +import java.io.IOException +import java.lang.Exception +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class ChatActivity: BottomSheetActivity(), + Injectable, + ChatActionListener, + ComposeAutoCompleteAdapter.AutocompletionProvider, + EmojiKeyboard.OnEmojiSelectedListener, + OnEmojiSelectedListener, + InputConnectionCompat.OnCommitContentListener { + private val TAG = "ChatsActivity" // logging tag + private val LOAD_AT_ONCE = 30 + + @Inject + lateinit var eventHub: EventHub + @Inject + lateinit var api: MastodonApi + @Inject + lateinit var chatsRepo: ChatRepository + @Inject + lateinit var serviceClient: ServiceClient + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @VisibleForTesting + val viewModel: ChatViewModel by viewModels { viewModelFactory } + @VisibleForTesting + var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT + + lateinit var adapter: ChatMessagesAdapter + + private val msgs = PairedList(Function { input -> + input.asRightOrNull()?.let(ViewDataUtils::chatMessageToViewData) ?: + ChatMessageViewData.Placeholder(input.asLeft().id, false) + }) + + private var bottomLoading = false + private var isNeedRefresh = false + private var didLoadEverythingBottom = false + private var initialUpdateFailed = false + private var haveStickers = false + + private lateinit var addMediaBehavior : BottomSheetBehavior<*> + private lateinit var emojiBehavior: BottomSheetBehavior<*> + private lateinit var stickerBehavior: BottomSheetBehavior<*> + + private var finishingUploadDialog: ProgressDialog? = null + private var photoUploadUri: Uri? = null + + private enum class FetchEnd { + TOP, BOTTOM, MIDDLE + } + + private val listUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + Log.d(TAG, "onInserted") + adapter.notifyItemRangeInserted(position, count) + if (position == 0) { + recycler.scrollToPosition(0) + } + } + + override fun onRemoved(position: Int, count: Int) { + Log.d(TAG, "onRemoved") + adapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + Log.d(TAG, "onMoved") + adapter.notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + Log.d(TAG, "onChanged") + adapter.notifyItemRangeChanged(position, count, payload) + } + } + + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Boolean { + return oldItem.getViewDataId() == newItem.getViewDataId() + } + + override fun areContentsTheSame(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload(oldItem: ChatMessageViewData, newItem: ChatMessageViewData): Any? { + return if (oldItem.deepEquals(newItem)) { + //If items are equal - update timestamp only + listOf(ChatMessagesViewHolder.Key.KEY_CREATED) + } else // If items are different - update a whole view holder + null + } + } + + private val differ = AsyncListDiffer(listUpdateCallback, + AsyncDifferConfig.Builder(diffCallback).build()) + + private val dataSource = object : TimelineAdapter.AdapterDataSource { + override fun getItemCount(): Int { + return differ.currentList.size + } + + override fun getItemAt(pos: Int): ChatMessageViewData { + return differ.currentList[pos] + } + } + + private lateinit var chatId : String + private lateinit var avatarUrl : String + private lateinit var displayName : String + private lateinit var username : String + private lateinit var emojis : ArrayList + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if(accountManager.activeAccount == null) { + throw Exception("No active account!") + } + + chatId = intent.getStringExtra(ID) ?: throw IllegalArgumentException("Can't open ChatActivity without chatId") + avatarUrl = intent.getStringExtra(AVATAR_URL) ?: throw IllegalArgumentException("Can't open ChatActivity without avatarUrl") + displayName = intent.getStringExtra(DISPLAY_NAME) ?: throw IllegalArgumentException("Can't open ChatActivity without displayName") + username = intent.getStringExtra(USERNAME) ?: throw IllegalArgumentException("Can't open ChatActivity without username") + emojis = intent.getParcelableArrayListExtra(EMOJIS) ?: throw IllegalArgumentException("Can't open ChatActivity without emojis") + + setContentView(R.layout.activity_chat) + setSupportActionBar(toolbar) + + subscribeToUpdates() + + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + viewModel.tryFetchStickers = preferences.getBoolean(PrefKeys.STICKERS, false) + viewModel.anonymizeNames = preferences.getBoolean(PrefKeys.ANONYMIZE_FILENAMES, false) + + setupHeader() + setupChat() + setupAttachment() + setupComposeField(savedInstanceState?.getString(MESSAGE_KEY)) + setupButtons() + + viewModel.setup() + + photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY) + + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event: Event? -> + when(event) { + is ChatMessageDeliveredEvent -> { + if(event.chatMsg.chatId == chatId) { + onRefresh() + enableButton(attachmentButton, true, true) + enableButton(stickerButton, haveStickers, haveStickers) + + sending = false + enableSendButton() + } + } + is ChatMessageReceivedEvent -> { + if(event.chatMsg.chatId == chatId) { + onRefresh() + } + } + } + } + + tryCache() + } + + private fun setupHeader() { + supportActionBar?.run { + title = "" + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + loadAvatar(avatarUrl, chatAvatar, resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),true) + chatTitle.text = displayName.emojify(emojis, chatTitle, true) + chatUsername.text = username + } + + private fun setupChat() { + adapter = ChatMessagesAdapter(dataSource, this, accountManager.activeAccount!!.accountId) + + // TODO: a11y + recycler.setHasFixedSize(true) + val layoutManager = LinearLayoutManager(this) + layoutManager.reverseLayout = true + recycler.layoutManager = layoutManager + // recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + recycler.adapter = adapter + } + + private fun setupAttachment() { + val onMediaPick = View.OnClickListener { view -> + val popup = PopupMenu(view.context, view) + val addCaptionId = 1 + val removeId = 2 + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + popup.menu.add(0, removeId, 0, R.string.action_remove) + popup.setOnMenuItemClickListener { menuItem -> + viewModel.media.value?.get(0)?.let { + when (menuItem.itemId) { + addCaptionId -> { + makeCaptionDialog(it.description, it.uri) { newDescription -> + viewModel.updateDescription(it.localId, newDescription) + } + } + removeId -> { + viewModel.removeMediaFromQueue(it) + } + } + } + true + } + popup.show() + } + + imageAttachment.setOnClickListener(onMediaPick) + textAttachment.setOnClickListener(onMediaPick) + } + + private fun setupComposeField(startingText: String?) { + editText.setOnCommitContentListener(this) + + editText.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } + + editText.setAdapter( + ComposeAutoCompleteAdapter(this)) + editText.setTokenizer(ComposeTokenizer()) + + editText.setText(startingText) + editText.setSelection(editText.length()) + + val mentionColour = editText.linkTextColors.defaultColor + highlightSpans(editText.text, mentionColour) + editText.afterTextChanged { editable -> + highlightSpans(editable, mentionColour) + enableSendButton() + } + + // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O + || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { + editText.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + } + } + + private var sending = false + private fun enableSendButton() { + if(sending) + return + + val haveMedia = viewModel.media.value?.isNotEmpty() ?: false + val haveText = editText.text.isNotEmpty() + + enableButton(sendButton, haveMedia || haveText, haveMedia || haveText) + } + + override fun search(token: String): List { + return viewModel.searchAutocompleteSuggestions(token) + } + + /** This is for the fancy keyboards which can insert images and stuff. */ + override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean { + // Verify the returned content's type is of the correct MIME type + val supported = inputContentInfo.description.hasMimeType("image/*") + + if(supported) { + val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 + if(lacksPermission) { + try { + inputContentInfo.requestPermission() + } catch (e: Exception) { + Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message) + return false + } + } + pickMedia(inputContentInfo.contentUri, inputContentInfo) + return true + } + + return false + } + + private fun subscribeToUpdates() { + withLifecycleContext { + viewModel.instanceParams.observe { instanceData -> + maximumTootCharacters = instanceData.chatLimit + } + viewModel.instanceStickers.observe { stickers -> + if(stickers.isNotEmpty()) { + haveStickers = true + stickerButton.visibility = View.VISIBLE + enableButton(stickerButton, true, true) + stickerKeyboard.setupStickerKeyboard(this@ChatActivity, stickers) + } + } + viewModel.emoji.observe { setEmojiList(it) } + viewModel.media.observe { + val notHaveMedia = it.isEmpty() + + enableSendButton() + enableButton(attachmentButton, notHaveMedia, notHaveMedia) + enableButton(stickerButton, haveStickers && notHaveMedia, haveStickers && notHaveMedia) + + if(!notHaveMedia) { + val media = it[0] + + when(media.type) { + ComposeActivity.QueuedMedia.UNKNOWN -> { + textAttachment.visibility = View.VISIBLE + imageAttachment.visibility = View.GONE + + textAttachment.text = media.originalFileName + textAttachment.setChecked(!media.description.isNullOrEmpty()) + textAttachment.setProgress(media.uploadPercent) + } + ComposeActivity.QueuedMedia.AUDIO -> { + imageAttachment.visibility = View.VISIBLE + textAttachment.visibility = View.GONE + + imageAttachment.setChecked(!media.description.isNullOrEmpty()) + imageAttachment.setProgress(media.uploadPercent) + imageAttachment.setImageResource(R.drawable.ic_music_box_preview_24dp) + } + else -> { + imageAttachment.visibility = View.VISIBLE + textAttachment.visibility = View.GONE + + imageAttachment.setChecked(!media.description.isNullOrEmpty()) + imageAttachment.setProgress(media.uploadPercent) + + Glide.with(imageAttachment.context) + .load(media.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(imageAttachment) + } + } + + attachmentLayout.visibility = View.VISIBLE + } else { + attachmentLayout.visibility = View.GONE + } + } + viewModel.uploadError.observe { + displayTransientError(R.string.error_media_upload_sending) + } + } + } + + private fun setEmojiList(emojiList: List?) { + if (emojiList != null) { + emojiView.adapter = EmojiAdapter(emojiList, this@ChatActivity) + enableButton(emojiButton, true, emojiList.isNotEmpty()) + } + } + + private fun replaceTextAtCaret(text: CharSequence) { + // If you select "backward" in an editable, you get SelectionStart > SelectionEnd + val start = editText.selectionStart.coerceAtMost(editText.selectionEnd) + val end = editText.selectionStart.coerceAtLeast(editText.selectionEnd) + val textToInsert = if (start > 0 && !editText.text[start - 1].isWhitespace()) { + " $text" + } else { + text + } + editText.text.replace(start, end, textToInsert) + + // Set the cursor after the inserted text + editText.setSelection(start + text.length) + } + + override fun onEmojiSelected(shortcode: String) { + replaceTextAtCaret(":$shortcode: ") + } + + override fun onEmojiSelected(id: String, shortcode: String) { + Glide.with(this).asFile().load(shortcode).into( object : CustomTarget() { + override fun onLoadCleared(placeholder: Drawable?) { + displayTransientError(R.string.error_sticker_fetch) + } + + override fun onResourceReady(resource: File, transition: Transition?) { + val cut = shortcode.lastIndexOf('/') + val filename = if(cut != -1) shortcode.substring(cut + 1) else "unknown.png" + pickMedia(resource.toUri(), null, filename) + } + }) + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + private fun setupButtons() { + addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet) + emojiBehavior = BottomSheetBehavior.from(emojiView) + stickerBehavior = BottomSheetBehavior.from(stickerKeyboard) + + sendButton.setOnClickListener { onSendClicked() } + + attachmentButton.setOnClickListener { openPickDialog() } + emojiButton.setOnClickListener { showEmojis() } + if(viewModel.tryFetchStickers) { + stickerButton.setOnClickListener { showStickers() } + stickerButton.visibility = View.VISIBLE + enableButton(stickerButton, false, false) + } else { + stickerButton.visibility = View.GONE + } + + emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false) + + val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + + val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 } + actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) + + val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 } + actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null) + + actionPhotoTake.setOnClickListener { initiateCameraApp() } + actionPhotoPick.setOnClickListener { onMediaPick() } + } + + private fun onSendClicked() { + val media = viewModel.getSingleMedia() + + serviceClient.sendChatMessage(MessageToSend( + editText.text.toString(), + media?.id, + media?.uri?.toString(), + accountManager.activeAccount!!.id, + this.chatId, + 0 + )) + + sending = true + editText.text.clear() + viewModel.media.value = listOf() + enableButton(sendButton, false, false) + enableButton(attachmentButton, false, false) + enableButton(stickerButton, false, false) + } + + private fun openPickDialog() { + if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + + private fun showEmojis() { + emojiView.adapter?.let { + if (it.itemCount == 0) { + val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } else { + if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + } + } + + private fun showStickers() { + if (stickerBehavior.state == BottomSheetBehavior.STATE_HIDDEN || stickerBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + stickerBehavior.state = BottomSheetBehavior.STATE_EXPANDED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + + private fun initiateCameraApp() { + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + + // We don't need to ask for permission in this case, because the used calls require + // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was + // way before permission dialogues have been introduced. + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (intent.resolveActivity(packageManager) != null) { + val photoFile: File = try { + createNewImageFile(this) + } catch (ex: IOException) { + displayTransientError(R.string.error_media_upload_opening) + return + } + + // Continue only if the File was successfully created + photoUploadUri = FileProvider.getUriForFile(this, + BuildConfig.APPLICATION_ID + ".fileprovider", + photoFile) + intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) + startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) + } + } + + private fun onMediaPick() { + addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + //Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (ContextCompat.checkSelfPermission(this@ChatActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this@ChatActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + } else { + initiateMediaPicking() + } + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + }) + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, + grantResults: IntArray) { + if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initiateMediaPicking() + } else { + val bar = Snackbar.make(activityChat, R.string.error_media_upload_permission, + Snackbar.LENGTH_SHORT).apply { + + } + bar.setAction(R.string.action_retry) { onMediaPick()} + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + } + } + + private fun initiateMediaPicking() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + + intent.type = "*/*" // Pleroma allows anything + startActivityForResult(intent, MEDIA_PICK_RESULT) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { + pickMedia(intent.data!!) + } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { + pickMedia(photoUploadUri!!) + } + } + + private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { + button.isEnabled = clickable + ThemeUtils.setDrawableTint(this, button.drawable, + if (colorActive) android.R.attr.textColorTertiary + else R.attr.textColorDisabled) + } + + private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) { + withLifecycleContext { + viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem -> + + contentInfoCompat?.releasePermission() + + if(exceptionOrItem.isLeft()) { + val errorId = when (val exception = exceptionOrItem.asLeft()) { + is VideoSizeException -> { + R.string.error_video_upload_size + } + is MediaSizeException -> { + R.string.error_media_upload_size + } + is AudioSizeException -> { + R.string.error_audio_upload_size + } + is VideoOrImageException -> { + R.string.error_media_upload_image_or_video + } + else -> { + Log.d(TAG, "That file could not be opened", exception) + R.string.error_media_upload_opening + } + } + displayTransientError(errorId) + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri) + outState.putString(MESSAGE_KEY, editText.text.toString()) + super.onSaveInstanceState(outState) + } + + private fun displayTransientError(@StringRes stringId: Int) { + val bar = Snackbar.make(activityChat, stringId, Snackbar.LENGTH_LONG) + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + + private fun clearPlaceholdersForResponse(msgs: MutableList) { + msgs.removeAll { it.isLeft() } + } + + private fun tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + chatsRepo.getChatMessages(chatId, null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { msgs -> + if (msgs.size > 1) { + val mutableMsgs = msgs.toMutableList() + clearPlaceholdersForResponse(mutableMsgs) + this.msgs.clear() + this.msgs.addAll(mutableMsgs) + updateAdapter() + progressBar.visibility = View.GONE + // Request statuses including current top to refresh all of them + } + updateCurrent() + loadAbove() + } + } + + private fun updateCurrent() { + if (msgs.isEmpty()) { + return + } + + val topId = msgs.first { it.isRight() }.asRight().id + chatsRepo.getChatMessages(chatId, topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe({ messages -> + initialUpdateFailed = false + // When cached timeline is too old, we would replace it with nothing + if (messages.isNotEmpty()) { + // clear old cached statuses + if(this.msgs.isNotEmpty()) { + this.msgs.removeAll { + if(it.isRight()) { + val chat = it.asRight() + chat.id.length < topId.length || chat.id < topId + } else { + val placeholder = it.asLeft() + placeholder.id.length < topId.length || placeholder.id < topId + } + } + } + this.msgs.addAll(messages) + updateAdapter() + } + bottomLoading = false + }, { + initialUpdateFailed = true + // Indicate that we are not loading anymore + progressBar.visibility = View.GONE + }) + } + + private fun showNothing() { + messageView.visibility = View.VISIBLE + messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + + private fun loadAbove() { + var firstOrNull: String? = null + var secondOrNull: String? = null + for (i in msgs.indices) { + val msg = msgs[i] + if (msg.isRight()) { + firstOrNull = msg.asRight().id + if (i + 1 < msgs.size && msgs[i + 1].isRight()) { + secondOrNull = msgs[i + 1].asRight().id + } + break + } + } + if (firstOrNull != null) { + sendFetchMessagesRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) + } else { + sendFetchMessagesRequest(null, null, null, FetchEnd.BOTTOM, -1) + } + } + + private fun sendFetchMessagesRequest(maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, + fetchEnd: FetchEnd, pos: Int) { + // allow getting old statuses/fallbacks for network only for for bottom loading + val mode = if (fetchEnd == FetchEnd.BOTTOM) { + TimelineRequestMode.ANY + } else { + TimelineRequestMode.NETWORK + } + chatsRepo.getChatMessages(chatId, maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( { result -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) }, + { onFetchTimelineFailure(Exception(it), fetchEnd, pos) }) + } + + private fun updateAdapter() { + Log.d(TAG, "updateAdapter") + differ.submitList(msgs.pairedCopy) + } + + private fun updateMessages(newMsgs: MutableList, fullFetch: Boolean) { + if (newMsgs.isEmpty()) { + updateAdapter() + return + } + if (msgs.isEmpty()) { + msgs.addAll(newMsgs) + } else { + val lastOfNew = newMsgs[newMsgs.size - 1] + val index = msgs.indexOf(lastOfNew) + if (index >= 0) { + msgs.subList(0, index).clear() + } + val newIndex = newMsgs.indexOf(msgs[0]) + if (newIndex == -1) { + if (index == -1 && fullFetch) { + newMsgs.findLast { it.isRight() }?.let { + val placeholderId = it.asRight().id.inc() + newMsgs.add(Either.Left(Placeholder(placeholderId))) + } + } + msgs.addAll(0, newMsgs) + } else { + msgs.addAll(0, newMsgs.subList(0, newIndex)) + } + } + // Remove all consecutive placeholders + removeConsecutivePlaceholders() + updateAdapter() + } + + private fun removeConsecutivePlaceholders() { + for (i in 0 until msgs.size - 1) { + if (msgs[i].isLeft() && msgs[i + 1].isLeft()) { + msgs.removeAt(i) + } + } + } + + private fun replacePlaceholderWithMessages(newMsgs: MutableList, + fullFetch: Boolean, pos: Int) { + val placeholder = msgs[pos] + if (placeholder.isLeft()) { + msgs.removeAt(pos) + } + if (newMsgs.isEmpty()) { + updateAdapter() + return + } + if (fullFetch) { + newMsgs.add(placeholder) + } + msgs.addAll(pos, newMsgs) + removeConsecutivePlaceholders() + updateAdapter() + } + + private fun addItems(newMsgs: List) { + if (newMsgs.isEmpty()) { + return + } + val last = msgs.findLast { it.isRight() } + + // I was about to replace findStatus with indexOf but it is incorrect to compare value + // types by ID anyway and we should change equals() for Status, I think, so this makes sense + if (last != null && !newMsgs.contains(last)) { + msgs.addAll(newMsgs) + removeConsecutivePlaceholders() + updateAdapter() + } + } + + private fun onFetchTimelineSuccess(msgs: MutableList, + fetchEnd: FetchEnd, pos: Int) { + + // We filled the hole (or reached the end) if the server returned less statuses than we + // we asked for. + val fullFetch = msgs.size >= LOAD_AT_ONCE + + when (fetchEnd) { + FetchEnd.TOP -> { + updateMessages(msgs, fullFetch) + + val last = msgs.indexOfFirst { it.isRight() } + + mastodonApi.markChatAsRead(chatId, msgs[last].asRight().id) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe({ + Log.d(TAG, "Marked new messages as read up to ${msgs[last].asRight().id}") + }, { + Log.d(TAG, "Failed to mark messages as read", it) + }) + } + FetchEnd.MIDDLE -> { + replacePlaceholderWithMessages(msgs, fullFetch, pos) + } + FetchEnd.BOTTOM -> { + if (this.msgs.isNotEmpty() && !this.msgs.last().isRight()) { + this.msgs.removeAt(this.msgs.size - 1) + updateAdapter() + } + + if (msgs.isNotEmpty() && !msgs.last().isRight()) { + // Removing placeholder if it's the last one from the cache + msgs.removeAt(msgs.size - 1) + } + + val oldSize = this.msgs.size + if (this.msgs.size > 1) { + addItems(msgs) + } else { + updateMessages(msgs, fullFetch) + } + + if (this.msgs.size == oldSize) { + // This may be a brittle check but seems like it works + // Can we check it using headers somehow? Do all server support them? + didLoadEverythingBottom = true + } + } + } + updateBottomLoadingState(fetchEnd) + progressBar.visibility = View.GONE + if (this.msgs.size == 0) { + showNothing() + } else { + messageView.visibility = View.GONE + } + } + + private fun onRefresh() { + messageView.visibility = View.GONE + isNeedRefresh = false + + if (this.initialUpdateFailed) { + updateCurrent() + } + loadAbove() + } + + private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) { + if (fetchEnd == FetchEnd.MIDDLE && !msgs[position].isRight()) { + var placeholder = msgs[position].asLeftOrNull() + val newViewData: ChatMessageViewData + if (placeholder == null) { + val msg = msgs[position - 1].asRight() + val newId = msg.id.dec() + placeholder = Placeholder(newId) + } + newViewData = ChatMessageViewData.Placeholder(placeholder.id, false) + msgs.setPairedItem(position, newViewData) + updateAdapter() + } else if (msgs.isEmpty()) { + messageView.visibility = View.VISIBLE + if (exception is IOException) { + messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + progressBar.visibility = View.VISIBLE + onRefresh() + } + } else { + messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + progressBar.visibility = View.VISIBLE + onRefresh() + } + } + } + Log.e(TAG, "Fetch Failure: " + exception.message) + updateBottomLoadingState(fetchEnd) + progressBar.visibility = View.GONE + } + + private fun updateBottomLoadingState(fetchEnd: FetchEnd) { + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false + } + } + + override fun onLoadMore(position: Int) { + //check bounds before accessing list, + if (msgs.size >= position && position > 0) { + val fromChat = msgs[position - 1].asRightOrNull() + val toChat = msgs[position + 1].asRightOrNull() + if (fromChat == null || toChat == null) { + Log.e(TAG, "Failed to load more at $position, wrong placeholder position") + return + } + + val maxMinusOne = if (msgs.size > position + 1 && msgs[position + 2].isRight()) msgs[position + 1].asRight().id else null + sendFetchMessagesRequest(fromChat.id, toChat.id, maxMinusOne, + FetchEnd.MIDDLE, position) + + val (id) = msgs[position].asLeft() + val newViewData = ChatMessageViewData.Placeholder(id, true) + msgs.setPairedItem(position, newViewData) + updateAdapter() + } else { + Log.e(TAG, "error loading more") + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + Log.d(TAG, event.toString()) + if(event.action == KeyEvent.ACTION_DOWN) { + if (event.isCtrlPressed) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // send message by pressing CTRL + ENTER + onSendClicked() + return true + } + } + + if (keyCode == KeyEvent.KEYCODE_BACK) { + onBackPressed() + return true + } + } + return super.onKeyDown(keyCode, event) + } + + + override fun onBackPressed() { + // Acting like a teen: deliberately ignoring parent. + if (addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN || + emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN || + stickerBehavior.state != BottomSheetBehavior.STATE_HIDDEN) { + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return + } + + finish() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + finish() + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onResume() { + super.onResume() + startUpdateTimestamp() + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private fun startUpdateTimestamp() { + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_PAUSE) + .subscribe { updateAdapter() } + } + } + + override fun onViewAccount(id: String) { + viewAccount(id) + } + + override fun onViewUrl(url: String) { + viewUrl(url) + } + + override fun onViewTag(tag: String) { + val intent = Intent(this, ViewTagActivity::class.java) + intent.putExtra("hashtag", tag) + startActivity(intent) + } + + override fun onViewMedia(position: Int, view: View?) { + val attachment = msgs[position].asRight().attachment!! + + when(attachment.type) { + Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.AUDIO, Attachment.Type.IMAGE -> { + val intent = ViewMediaActivity.newIntent(this, attachment) + if(view != null) { + val url = attachment.url + ViewCompat.setTransitionName(view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, url) + + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Attachment.Type.UNKNOWN -> { + viewUrl(attachment.url) + } + } + } + + companion object { + private const val MEDIA_PICK_RESULT = 1 + private const val MEDIA_TAKE_PHOTO_RESULT = 2 + private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 + private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" + private const val MESSAGE_KEY = "MESSAGE" + + fun getIntent(context: Context, chat: Chat) : Intent { + val intent = Intent(context, ChatActivity::class.java) + intent.putExtra(ID, chat.id) + intent.putExtra(AVATAR_URL, chat.account.avatar) + intent.putExtra(DISPLAY_NAME, chat.account.displayName ?: chat.account.localUsername) + intent.putParcelableArrayListExtra(EMOJIS, ArrayList(chat.account.emojis ?: emptyList())) + intent.putExtra(USERNAME, chat.account.username) + return intent + } + + const val ID = "id" + const val AVATAR_URL = "avatar_url" + const val DISPLAY_NAME = "display_name" + const val USERNAME = "username" + const val EMOJIS = "emojis" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt new file mode 100644 index 0000000..d9a855f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt @@ -0,0 +1,29 @@ +package com.keylesspalace.tusky.components.chat + +import com.keylesspalace.tusky.components.common.CommonComposeViewModel +import com.keylesspalace.tusky.components.common.MediaUploader +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.util.* +import javax.inject.Inject + +open class ChatViewModel +@Inject constructor( + private val api: MastodonApi, + private val accountManager: AccountManager, + private val mediaUploader: MediaUploader, + private val serviceClient: ServiceClient, + private val saveTootHelper: SaveTootHelper, + private val db: AppDatabase +) : CommonComposeViewModel(api, accountManager, mediaUploader, db) { + + fun getSingleMedia() : ComposeActivity.QueuedMedia? { + return if(media.value?.isNotEmpty() == true) + media.value?.get(0) + else null + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt new file mode 100644 index 0000000..4e152b2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt @@ -0,0 +1,382 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ +package com.keylesspalace.tusky.components.common + +import android.net.Uri +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.Single +import io.reactivex.disposables.Disposable +import io.reactivex.rxkotlin.Singles +import retrofit2.Response +import java.util.* +import javax.inject.Inject + +open class CommonComposeViewModel( + private val api: MastodonApi, + private val accountManager: AccountManager, + private val mediaUploader: MediaUploader, + private val db: AppDatabase +) : RxAwareViewModel() { + + protected val instance: MutableLiveData = MutableLiveData(null) + protected val nodeinfo: MutableLiveData = MutableLiveData(null) + protected val stickers: MutableLiveData> = MutableLiveData(emptyArray()) + val haveStickers: MutableLiveData = MutableLiveData(false) + var tryFetchStickers = false + var anonymizeNames = true + var hasNoAttachmentLimits = false + + val instanceParams: LiveData = instance.map { instance -> + ComposeInstanceParams( + maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + chatLimit = instance?.chatLimit ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false + ) + } + val instanceMetadata: LiveData = nodeinfo.map { nodeinfo -> + val software = nodeinfo?.software?.name ?: "mastodon" + + if(software.equals("pleroma")) { + hasNoAttachmentLimits = true + ComposeInstanceMetadata( + software = "pleroma", + supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false, + supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false, + supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false, + videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT, + imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT + ) + } else if(software.equals("pixelfed")) { + ComposeInstanceMetadata( + software = "pixelfed", + supportsMarkdown = false, + supportsBBcode = false, + supportsHTML = false, + videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT, + imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT + ) + } else { + ComposeInstanceMetadata( + software = "mastodon", + supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false, + supportsBBcode = false, + supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false, + videoLimit = STATUS_VIDEO_SIZE_LIMIT, + imageLimit = STATUS_IMAGE_SIZE_LIMIT + ) + } + } + val instanceStickers: LiveData> = stickers // .map { stickers -> HashMap(stickers) } + + val emoji: MutableLiveData?> = MutableLiveData() + + val media = mutableLiveData>(listOf()) + val uploadError = MutableLiveData() + + protected val mediaToDisposable = mutableMapOf() + + init { + Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> + InstanceEntity( + instance = accountManager.activeAccount?.domain!!, + emojiList = emojis, + maximumTootCharacters = instance.maxTootChars, + maxPollOptions = instance.pollLimits?.maxOptions, + maxPollOptionLength = instance.pollLimits?.maxOptionChars, + version = instance.version, + chatLimit = instance.chatLimit + ) + } + .doOnSuccess { + db.instanceDao().insertOrReplace(it) + } + .onErrorResumeNext( + db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + ) + .subscribe ({ instanceEntity -> + emoji.postValue(instanceEntity.emojiList) + instance.postValue(instanceEntity) + }, { throwable -> + // this can happen on network error when no cached data is available + Log.w(TAG, "error loading instance data", throwable) + }) + .autoDispose() + + + api.getNodeinfoLinks().subscribe({ + links -> if(links.links.isNotEmpty()) { + api.getNodeinfo(links.links[0].href).subscribe({ + ni -> nodeinfo.postValue(ni) + }, { + err -> Log.d(TAG, "Failed to get nodeinfo", err) + }).autoDispose() + } + }, { err -> + Log.d(TAG, "Failed to get nodeinfo links", err) + }).autoDispose() + } + + fun pickMedia(uri: Uri, filename: String?): LiveData> { + // We are not calling .toLiveData() here because we don't want to stop the process when + // the Activity goes away temporarily (like on screen rotation). + val liveData = MutableLiveData>() + val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT + val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT + + mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename) + .map { (type, uri, size) -> + val mediaItems = media.value!! + if (!hasNoAttachmentLimits + && type != QueuedMedia.Type.IMAGE + && mediaItems.isNotEmpty() + && mediaItems[0].type == QueuedMedia.Type.IMAGE) { + throw VideoOrImageException() + } else { + addMediaToQueue(type, uri, size, filename ?: "unknown", anonymizeNames) + } + } + .subscribe({ queuedMedia -> + liveData.postValue(Either.Right(queuedMedia)) + }, { error -> + liveData.postValue(Either.Left(error)) + }) + .autoDispose() + return liveData + } + + private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String, anonymizeNames: Boolean): QueuedMedia { + val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename, + hasNoAttachmentLimits, anonymizeNames) + val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT + val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT + + media.value = media.value!! + mediaItem + mediaToDisposable[mediaItem.localId] = mediaUploader + .uploadMedia(mediaItem, videoLimit, imageLimit ) + .subscribe ({ event -> + val item = media.value?.find { it.localId == mediaItem.localId } + ?: return@subscribe + val newMediaItem = when (event) { + is UploadEvent.ProgressEvent -> + item.copy(uploadPercent = event.percentage) + is UploadEvent.FinishedEvent -> + item.copy(id = event.attachment.id, uploadPercent = -1) + } + synchronized(media) { + val mediaValue = media.value!! + val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } + media.postValue(if (index == -1) { + mediaValue + newMediaItem + } else { + mediaValue.toMutableList().also { it[index] = newMediaItem } + }) + } + }, { error -> + media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) + uploadError.postValue(error) + }) + return mediaItem + } + + protected fun addUploadedMedia(id: String, type: Int, uri: Uri, description: String?) { + val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown", + hasNoAttachmentLimits, anonymizeNames, -1, id, description) + media.value = media.value!! + mediaItem + } + + fun removeMediaFromQueue(item: QueuedMedia) { + mediaToDisposable[item.localId]?.dispose() + media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } + } + + fun updateDescription(localId: Long, description: String): LiveData { + val newList = media.value!!.toMutableList() + val index = newList.indexOfFirst { it.localId == localId } + if (index != -1) { + newList[index] = newList[index].copy(description = description) + } + media.value = newList + val completedCaptioningLiveData = MutableLiveData() + media.observeForever(object : Observer> { + override fun onChanged(mediaItems: List) { + val updatedItem = mediaItems.find { it.localId == localId } + if (updatedItem == null) { + media.removeObserver(this) + } else if (updatedItem.id != null) { + api.updateMedia(updatedItem.id, description) + .subscribe({ + completedCaptioningLiveData.postValue(true) + }, { + completedCaptioningLiveData.postValue(false) + }) + .autoDispose() + media.removeObserver(this) + } + } + }) + return completedCaptioningLiveData + } + + fun searchAutocompleteSuggestions(token: String): List { + when (token[0]) { + '@' -> { + return try { + val acct = token.substring(1) + api.searchAccounts(query = acct, resolve = true, limit = 10) + .blockingGet() + .map { ComposeAutoCompleteAdapter.AccountResult(it) } + .filter { + it.account.username.startsWith(acct, ignoreCase = true) + } + } catch (e: Throwable) { + Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) + emptyList() + } + } + '#' -> { + return try { + api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .blockingGet() + .hashtags + .map { ComposeAutoCompleteAdapter.HashtagResult(it) } + } catch (e: Throwable) { + Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) + emptyList() + } + } + ':' -> { + val emojiList = emoji.value ?: return emptyList() + + val incomplete = token.substring(1).toLowerCase(Locale.ROOT) + val results = ArrayList() + val resultsInside = ArrayList() + for (emoji in emojiList) { + val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT) + if (shortcode.startsWith(incomplete)) { + results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) + } else if (shortcode.indexOf(incomplete, 1) != -1) { + resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) + } + } + if (results.isNotEmpty() && resultsInside.isNotEmpty()) { + results.add(ComposeAutoCompleteAdapter.ResultSeparator()) + } + results.addAll(resultsInside) + return results + } + else -> { + Log.w(TAG, "Unexpected autocompletion token: $token") + return emptyList() + } + } + } + + override fun onCleared() { + for (uploadDisposable in mediaToDisposable.values) { + uploadDisposable.dispose() + } + super.onCleared() + } + + private fun getStickers() { + if(!tryFetchStickers) + return + + api.getStickers().subscribe({ stickers -> + if (stickers.isNotEmpty()) { + haveStickers.postValue(true) + + val singles = mutableListOf>>() + + for(entry in stickers) { + val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json"; + singles += api.getStickerPack(url) + } + + Single.zip(singles) { + it.map { + it as Response + it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json") + it.body()!! + } + }.onErrorReturn { + Log.d(TAG, "Failed to get sticker pack.json", it) + emptyList() + }.subscribe() { pack -> + if(pack.isNotEmpty()) { + val array = pack.toTypedArray() + array.sort() + this.stickers.postValue(array) + } + }.autoDispose() + } + }, { + err -> Log.d(TAG, "Failed to get sticker.json", err) + }).autoDispose() + } + + fun setup() { + getStickers() // early as possible + } + + private companion object { + const val TAG = "CCVM" + } + +} + +fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } + +const val DEFAULT_CHARACTER_LIMIT = 500 +const val DEFAULT_MAX_OPTION_COUNT = 4 +const val DEFAULT_MAX_OPTION_LENGTH = 25 +const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB +const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB + +data class ComposeInstanceParams( + val maxChars: Int, + val chatLimit: Int, + val pollMaxOptions: Int, + val pollMaxLength: Int, + val supportsScheduled: Boolean +) + +data class ComposeInstanceMetadata( + val software: String, + val supportsMarkdown: Boolean, + val supportsBBcode: Boolean, + val supportsHTML: Boolean, + val videoLimit: Long, + val imageLimit: Long +) + +/** + * Throw when trying to add an image when video is already present or the other way around + */ +class VideoOrImageException : Exception() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java new file mode 100644 index 0000000..c42a5d3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java @@ -0,0 +1,154 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.common; + +import android.content.ContentResolver; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.AsyncTask; + +import com.keylesspalace.tusky.util.IOUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize; +import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation; +import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap; + +/** + * Reduces the file size of images to fit under a given limit by resizing them, maintaining both + * aspect ratio and orientation. + */ +public class DownsizeImageTask extends AsyncTask { + private int sizeLimit; + private ContentResolver contentResolver; + private Listener listener; + private File tempFile; + + /** + * @param sizeLimit the maximum number of bytes each image can take + * @param contentResolver to resolve the specified images' URIs + * @param tempFile the file where the result will be stored + * @param listener to whom the results are given + */ + public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) { + this.sizeLimit = sizeLimit; + this.contentResolver = contentResolver; + this.tempFile = tempFile; + this.listener = listener; + } + + @Override + protected Boolean doInBackground(Uri... uris) { + boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile); + if (isCancelled()) { + return false; + } + return result; + } + + @Override + protected void onPostExecute(Boolean successful) { + if (successful) { + listener.onSuccess(tempFile); + } else { + listener.onFailure(); + } + super.onPostExecute(successful); + } + + public static boolean resize(Uri[] uris, long sizeLimit, ContentResolver contentResolver, + File tempFile) { + for (Uri uri : uris) { + InputStream inputStream; + try { + inputStream = contentResolver.openInputStream(uri); + } catch (FileNotFoundException e) { + return false; + } + // Initially, just get the image dimensions. + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(inputStream, null, options); + IOUtils.closeQuietly(inputStream); + // Get EXIF data, for orientation info. + int orientation = getImageOrientation(uri, contentResolver); + /* Unfortunately, there isn't a determined worst case compression ratio for image + * formats. So, the only way to tell if they're too big is to compress them and + * test, and keep trying at smaller sizes. The initial estimate should be good for + * many cases, so it should only iterate once, but the loop is used to be absolutely + * sure it gets downsized to below the limit. */ + int scaledImageSize = 1024; + do { + OutputStream stream; + try { + stream = new FileOutputStream(tempFile); + } catch (FileNotFoundException e) { + return false; + } + try { + inputStream = contentResolver.openInputStream(uri); + } catch (FileNotFoundException e) { + return false; + } + options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize); + options.inJustDecodeBounds = false; + Bitmap scaledBitmap; + try { + scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options); + } catch (OutOfMemoryError error) { + return false; + } finally { + IOUtils.closeQuietly(inputStream); + } + if (scaledBitmap == null) { + return false; + } + Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation); + if (reorientedBitmap == null) { + scaledBitmap.recycle(); + return false; + } + Bitmap.CompressFormat format; + /* It's not likely the user will give transparent images over the upload limit, but + * if they do, make sure the transparency is retained. */ + if (!reorientedBitmap.hasAlpha()) { + format = Bitmap.CompressFormat.JPEG; + } else { + format = Bitmap.CompressFormat.PNG; + } + reorientedBitmap.compress(format, 85, stream); + reorientedBitmap.recycle(); + scaledImageSize /= 2; + } while (tempFile.length() > sizeLimit); + } + return true; + } + + /** + * Used to communicate the results of the task. + */ + public interface Listener { + void onSuccess(File file); + + void onFailure(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt new file mode 100644 index 0000000..a2f06d2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt @@ -0,0 +1,261 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.common + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.ProgressRequestBody +import com.keylesspalace.tusky.util.* +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.* + +sealed class UploadEvent { + data class ProgressEvent(val percentage: Int) : UploadEvent() + data class FinishedEvent(val attachment: Attachment) : UploadEvent() +} + +fun createNewImageFile(context: Context, name: String = "Photo"): File { + // Create an image file name + val randomId = randomAlphanumericString(4) + val imageFileName = "${name}_${randomId}" + val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile( + imageFileName, /* prefix */ + ".jpg", /* suffix */ + storageDir /* directory */ + ) +} + +data class PreparedMedia(val type: Int, val uri: Uri, val size: Long) + +interface MediaUploader { + fun prepareMedia(inUri: Uri, videoLimit: Long, imageLimit: Long, filename: String?): Single + fun uploadMedia(media: QueuedMedia, videoLimit: Long, imageLimit: Long): Observable +} + +class AudioSizeException : Exception() +class VideoSizeException : Exception() +class MediaSizeException : Exception() +class MediaTypeException : Exception() +class CouldNotOpenFileException : Exception() + +class MediaUploaderImpl( + private val context: Context, + private val mastodonApi: MastodonApi +) : MediaUploader { + override fun uploadMedia(media: QueuedMedia, videoLimit: Long, imageLimit: Long): Observable { + return Observable + .fromCallable { + if (shouldResizeMedia(media, imageLimit)) { + downsize(media, imageLimit) + } else media + } + .switchMap { upload(it) } + .subscribeOn(Schedulers.io()) + } + + private fun getMimeTypeAndSuffixFromFilenameOrUri(uri: Uri, filename: String?) : Pair { + val mimeType = contentResolver.getType(uri) + return if(mimeType == null && filename != null) { + val extension = filename.substringAfterLast('.', "tmp") + Pair(MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension), ".$extension") + } else { + Pair(mimeType, "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")) + } + } + + override fun prepareMedia(inUri: Uri, videoLimit: Long, imageLimit: Long, filename: String?): Single { + return Single.fromCallable { + var mediaSize = getMediaSize(contentResolver, inUri) + var uri = inUri + val (mimeType, suffix) = getMimeTypeAndSuffixFromFilenameOrUri(uri, filename) + + try { + contentResolver.openInputStream(inUri).use { input -> + if (input == null) { + Log.w(TAG, "Media input is null") + uri = inUri + return@use + } + val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) + FileOutputStream(file.absoluteFile).use { out -> + input.copyTo(out) + uri = FileProvider.getUriForFile(context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file) + mediaSize = getMediaSize(contentResolver, uri) + } + + } + } catch (e: IOException) { + Log.w(TAG, e) + uri = inUri + } + if (mediaSize == MEDIA_SIZE_UNKNOWN) { + throw CouldNotOpenFileException() + } + + if (mimeType != null) { + val topLevelType = mimeType.substring(0, mimeType.indexOf('/')) + when (topLevelType) { + "video" -> { + if (mediaSize > videoLimit) { + throw VideoSizeException() + } + PreparedMedia(QueuedMedia.VIDEO, uri, mediaSize) + } + "image" -> { + PreparedMedia(QueuedMedia.IMAGE, uri, mediaSize) + } + "audio" -> { + if (mediaSize > videoLimit) { // TODO: CHANGE!!11 + throw AudioSizeException() + } + PreparedMedia(QueuedMedia.AUDIO, uri, mediaSize) + } + else -> { + if (mediaSize > videoLimit) { + throw MediaSizeException() + } + PreparedMedia(QueuedMedia.UNKNOWN, uri, mediaSize) + // throw MediaTypeException() + } + } + } else { + throw MediaTypeException() + } + } + } + + private val contentResolver = context.contentResolver + + private fun upload(media: QueuedMedia): Observable { + return Observable.create { emitter -> + var (mimeType, fileExtension) = getMimeTypeAndSuffixFromFilenameOrUri(media.uri, media.originalFileName) + val filename = if(!media.anonymizeFileName) media.originalFileName else + String.format("%s_%s_%s%s", + context.getString(R.string.app_name), + Date().time.toString(), + randomAlphanumericString(10), + fileExtension) + + val stream = contentResolver.openInputStream(media.uri) + + if (mimeType == null) mimeType = "multipart/form-data" + + var lastProgress = -1 + val fileBody = ProgressRequestBody(stream, media.mediaSize, + mimeType.toMediaTypeOrNull()) { percentage -> + if (percentage != lastProgress) { + emitter.onNext(UploadEvent.ProgressEvent(percentage)) + } + lastProgress = percentage + } + + val body = MultipartBody.Part.createFormData("file", filename, fileBody) + + val description = if (media.description != null) { + MultipartBody.Part.createFormData("description", media.description) + } else { + null + } + + val uploadDisposable = mastodonApi.uploadMedia(body, description) + .subscribe({ attachment -> + emitter.onNext(UploadEvent.FinishedEvent(attachment)) + emitter.onComplete() + }, { e -> + emitter.onError(e) + }) + + // Cancel the request when our observable is cancelled + emitter.setDisposable(uploadDisposable) + } + } + + private fun downsize(media: QueuedMedia, imageLimit: Long): QueuedMedia { + val file = createNewImageFile(context, media.originalFileName) + DownsizeImageTask.resize(arrayOf(media.uri), imageLimit, context.contentResolver, file) + return media.copy(uri = file.toUri(), mediaSize = file.length()) + } + + private fun shouldResizeMedia(media: QueuedMedia, imageLimit: Long): Boolean { + // resize only images + if(media.type == QueuedMedia.Type.IMAGE) { + // resize when exceed image limit + if(media.mediaSize >= imageLimit) + return true + + // don't resize when instance permits any image resolution(Pleroma) + if(media.noChanges) + return false + + // resize when exceed pixel limit + if(getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT) + return true + } + + return false + } + + private companion object { + private const val TAG = "MediaUploaderImpl" + private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels + } +} + +fun Uri.toFileName(contentResolver: ContentResolver? = null): String { + var result: String = "unknown" + + if(scheme.equals("content") && contentResolver != null) { + val cursor = contentResolver.query(this, null, null, null, null) + cursor?.use{ + if(it.moveToFirst()) { + result = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + } + + if(result.equals("unknown")) { + path?.let { + result = it + val cut = result.lastIndexOf('/') + if (cut != -1) { + result = result.substring(cut + 1) + } + } + } + return result +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt new file mode 100644 index 0000000..5539d10 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -0,0 +1,1370 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.Manifest +import android.app.Activity +import android.app.ProgressDialog +import android.app.TimePickerDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.activity.viewModels +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.core.view.inputmethod.InputContentInfoCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.transition.TransitionManager +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.EmojiAdapter +import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.common.* +import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog +import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog +import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.EmojiKeyboard +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.activity_compose.* +import java.io.File +import java.io.IOException +import java.util.* +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min +import me.thanel.markdownedit.MarkdownEdit +import io.reactivex.android.schedulers.AndroidSchedulers +import com.uber.autodispose.android.lifecycle.autoDispose + +class ComposeActivity : BaseActivity(), + ComposeOptionsListener, + ComposeAutoCompleteAdapter.AutocompletionProvider, + OnEmojiSelectedListener, + Injectable, + InputConnectionCompat.OnCommitContentListener, + TimePickerDialog.OnTimeSetListener, + EmojiKeyboard.OnEmojiSelectedListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var eventHub: EventHub + + private lateinit var composeOptionsBehavior: BottomSheetBehavior<*> + private lateinit var addMediaBehavior: BottomSheetBehavior<*> + private lateinit var emojiBehavior: BottomSheetBehavior<*> + private lateinit var scheduleBehavior: BottomSheetBehavior<*> + private lateinit var stickerBehavior: BottomSheetBehavior<*> + private lateinit var previewBehavior: BottomSheetBehavior<*> + + // this only exists when a status is trying to be sent, but uploads are still occurring + private var finishingUploadDialog: ProgressDialog? = null + private var photoUploadUri: Uri? = null + + @VisibleForTesting + var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT + + @VisibleForTesting + val viewModel: ComposeViewModel by viewModels { viewModelFactory } + private var suggestFormattingSyntax: String = "text/markdown" + + private val maxUploadMediaNumber = 4 + private var mediaCount = 0 + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + if (theme == "black") { + setTheme(R.style.TuskyDialogActivityBlackTheme) + } + setContentView(R.layout.activity_compose) + + setupActionBar() + // do not do anything when not logged in, activity will be finished in super.onCreate() anyway + val activeAccount = accountManager.activeAccount ?: return + + viewModel.tryFetchStickers = preferences.getBoolean(PrefKeys.STICKERS, false) + viewModel.anonymizeNames = preferences.getBoolean(PrefKeys.ANONYMIZE_FILENAMES, false) + setupAvatar(preferences, activeAccount) + val mediaAdapter = MediaPreviewAdapter( + this, + onAddCaption = { item -> + makeCaptionDialog(item.description, item.uri) { newDescription -> + viewModel.updateDescription(item.localId, newDescription) + } + }, + onRemove = this::removeMediaFromQueue + ) + composeMediaPreviewBar.layoutManager = + LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + composeMediaPreviewBar.adapter = mediaAdapter + composeMediaPreviewBar.itemAnimator = null + + // set before subscribing to updates to not accidentally catch it + viewModel.formattingSyntax.value = activeAccount.defaultFormattingSyntax + + subscribeToUpdates(mediaAdapter, activeAccount) + setupButtons() + + photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY) + + /* If the composer is started up as a reply to another post, override the "starting" state + * based on what the intent from the reply request passes. */ + + val composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) + + if (!composeOptions?.formattingSyntax.isNullOrEmpty()) { + suggestFormattingSyntax = composeOptions?.formattingSyntax!! + } else { + suggestFormattingSyntax = activeAccount.defaultFormattingSyntax + } + + viewModel.setup(composeOptions) + setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) + val tootText = composeOptions?.tootText + if (!tootText.isNullOrEmpty()) { + composeEditField.setText(tootText) + } + + if (!composeOptions?.scheduledAt.isNullOrEmpty()) { + composeScheduleView.setDateTime(composeOptions?.scheduledAt) + } + + setupComposeField(viewModel.startingText) + setupContentWarningField(composeOptions?.contentWarning) + setupPollView() + applyShareIntent(intent, savedInstanceState) + viewModel.setupComplete.value = true + + stickerKeyboard.isSticky = true + + eventHub.events.observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event: Event? -> + when(event) { + is StatusPreviewEvent -> onStatusPreviewReady(event.status) + } + } + } + + private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + /* Get incoming images being sent through a share action from another app. Only do this + * when savedInstanceState is null, otherwise both the images from the intent and the + * instance state will be re-queued. */ + intent.type?.also { type -> + if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { + when (intent.action) { + Intent.ACTION_SEND -> { + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uri -> + pickMedia(uri) + } + } + Intent.ACTION_SEND_MULTIPLE -> { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.forEach { uri -> + pickMedia(uri) + } + } + } + } else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) { + + val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) + val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() + val shareBody = if (!subject.isNullOrBlank() && subject !in text) { + subject + '\n' + text + } else { + text + } + + if (shareBody.isNotBlank()) { + val start = composeEditField.selectionStart.coerceAtLeast(0) + val end = composeEditField.selectionEnd.coerceAtLeast(0) + val left = min(start, end) + val right = max(start, end) + composeEditField.text.replace(left, right, shareBody, 0, shareBody.length) + // move edittext cursor to first when shareBody parsed + composeEditField.text.insert(0, "\n") + composeEditField.setSelection(0) + } + } + } + } + } + + private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) { + if (replyingStatusAuthor != null) { + composeReplyView.show() + composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) + val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 } + + ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) + composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) + + composeReplyView.setOnClickListener { + TransitionManager.beginDelayedTransition(composeReplyContentView.parent as ViewGroup) + + if (composeReplyContentView.isVisible) { + composeReplyContentView.hide() + composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) + } else { + composeReplyContentView.show() + val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 } + + ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) + composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null) + } + } + } + replyingStatusContent?.let { composeReplyContentView.text = it } + } + + private fun setupContentWarningField(startingContentWarning: String?) { + if (startingContentWarning != null) { + composeContentWarningField.setText(startingContentWarning) + } + composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + } + + private fun setupComposeField(startingText: String?) { + composeEditField.setOnCommitContentListener(this) + + composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } + + composeEditField.setAdapter( + ComposeAutoCompleteAdapter(this)) + composeEditField.setTokenizer(ComposeTokenizer()) + + composeEditField.setText(startingText) + composeEditField.setSelection(composeEditField.length()) + + val mentionColour = composeEditField.linkTextColors.defaultColor + highlightSpans(composeEditField.text, mentionColour) + composeEditField.afterTextChanged { editable -> + highlightSpans(editable, mentionColour) + updateVisibleCharactersLeft() + } + + // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O + || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { + composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + } + } + + private fun reenableAttachments() { + // in case of we already had disabled attachments + // but got information about extension later + enableButton(composeAddMediaButton, true, true) + enablePollButton(true) + } + + @VisibleForTesting + var supportedFormattingSyntax = arrayListOf() + + private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter, activeAccount: AccountEntity) { + withLifecycleContext { + viewModel.instanceParams.observe { instanceData -> + maximumTootCharacters = instanceData.maxChars + updateVisibleCharactersLeft() + composeScheduleButton.visible(instanceData.supportsScheduled) + } + viewModel.instanceMetadata.observe { instanceData -> + if(instanceData.supportsMarkdown) { + supportedFormattingSyntax.add("text/markdown") + } + + if(instanceData.supportsBBcode) { + supportedFormattingSyntax.add("text/bbcode") + } + + if(instanceData.supportsHTML) { + supportedFormattingSyntax.add("text/html") + } + + if(supportedFormattingSyntax.size != 0) { + composeFormattingSyntax.visible(true) + + val supportsPrefferedSyntax = supportedFormattingSyntax.contains(viewModel.formattingSyntax.value!!) + + if(!supportsPrefferedSyntax) { + suggestFormattingSyntax = if(supportedFormattingSyntax.contains(activeAccount.defaultFormattingSyntax)) + activeAccount.defaultFormattingSyntax + else supportedFormattingSyntax[0] + + viewModel.formattingSyntax.value = "" + } + } + + if(instanceData.software == "pleroma") { + composePreviewButton.visibility = View.VISIBLE + reenableAttachments() + } + } + viewModel.haveStickers.observe { haveStickers -> + if (haveStickers) { + composeStickerButton.visibility = View.VISIBLE + } + } + viewModel.instanceStickers.observe { stickers -> + /*for(sticker in stickers) + Log.d(TAG, "Found sticker pack: %s from %s".format(sticker.title, sticker.internal_url))*/ + + if(stickers.isNotEmpty()) { + composeStickerButton.visibility = View.VISIBLE + enableButton(composeStickerButton, true, true) + stickerKeyboard.setupStickerKeyboard(this@ComposeActivity, stickers) + } + } + viewModel.emoji.observe { emoji -> setEmojiList(emoji) } + combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> + updateSensitiveMediaToggle(markSensitive, showContentWarning) + showContentWarning(showContentWarning) + }.subscribe() + viewModel.statusVisibility.observe { visibility -> + setStatusVisibility(visibility) + } + viewModel.media.observe { media -> + mediaAdapter.submitList(media) + if (media.size != mediaCount) { + mediaCount = media.size + composeMediaPreviewBar.visible(media.isNotEmpty()) + updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) + } + } + viewModel.poll.observe { poll -> + pollPreview.visible(poll != null) + poll?.let(pollPreview::setPoll) + } + viewModel.scheduledAt.observe { scheduledAt -> + if (scheduledAt == null) { + composeScheduleView.resetSchedule() + } else { + composeScheduleView.setDateTime(scheduledAt) + } + updateScheduleButton() + } + combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> + if(!viewModel.hasNoAttachmentLimits) { + val active = poll == null && media!!.size != 4 + && (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) + enableButton(composeAddMediaButton, active, active) + enablePollButton(media.isNullOrEmpty()) + } + }.subscribe() + viewModel.uploadError.observe { + displayTransientError(R.string.error_media_upload_sending) + } + viewModel.setupComplete.observe { + // Focus may have changed during view model setup, ensure initial focus is on the edit field + composeEditField.requestFocus() + } + viewModel.formattingSyntax.observe { + if(it.isEmpty()) { + enableFormattingSyntaxButton(suggestFormattingSyntax, false) + setIconForSyntax(suggestFormattingSyntax, false) + } else { + val enable = it == suggestFormattingSyntax + + enableFormattingSyntaxButton(it, enable) + setIconForSyntax(it, enable) + } + } + } + } + + private fun setupButtons() { + composeOptionsBottomSheet.listener = this + + composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsBottomSheet) + addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet) + scheduleBehavior = BottomSheetBehavior.from(composeScheduleView) + emojiBehavior = BottomSheetBehavior.from(emojiView) + stickerBehavior = BottomSheetBehavior.from(stickerKeyboard) + previewBehavior = BottomSheetBehavior.from(previewScroll) + + enableButton(composeEmojiButton, clickable = false, colorActive = false) + enableButton(composeStickerButton, false, false) + + // Setup the interface buttons. + composeTootButton.setOnClickListener { onSendClicked(false) } + composePreviewButton.setOnClickListener { onSendClicked(true) } + composeAddMediaButton.setOnClickListener { openPickDialog() } + composeToggleVisibilityButton.setOnClickListener { showComposeOptions() } + composeContentWarningButton.setOnClickListener { onContentWarningChanged() } + composeEmojiButton.setOnClickListener { showEmojis() } + composeHideMediaButton.setOnClickListener { toggleHideMedia() } + composeScheduleButton.setOnClickListener { onScheduleClick() } + composeScheduleView.setResetOnClickListener { resetSchedule() } + composeFormattingSyntax.setOnClickListener { toggleFormattingMode() } + composeFormattingSyntax.setOnLongClickListener { selectFormattingSyntax() } + composeStickerButton.setOnClickListener { showStickers() } + atButton.setOnClickListener { atButtonClicked() } + hashButton.setOnClickListener { hashButtonClicked() } + codeButton.setOnClickListener { codeButtonClicked() } + linkButton.setOnClickListener { linkButtonClicked() } + strikethroughButton.setOnClickListener { strikethroughButtonClicked() } + italicButton.setOnClickListener { italicButtonClicked() } + boldButton.setOnClickListener { boldButtonClicked() } + + val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + + val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 } + actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) + + val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 } + actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null) + + val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 } + addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null) + + actionPhotoTake.setOnClickListener { initiateCameraApp() } + actionPhotoPick.setOnClickListener { onMediaPick() } + addPollTextActionTextView.setOnClickListener { openPollDialog() } + } + + private fun setupActionBar() { + setSupportActionBar(toolbar) + supportActionBar?.run { + title = null + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_close_24dp) + } + + } + + private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { + val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) + val a = obtainStyledAttributes(null, actionBarSizeAttr) + val avatarSize = a.getDimensionPixelSize(0, 1) + a.recycle() + + val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + loadAvatar( + activeAccount.profilePictureUrl, + composeAvatar, + avatarSize / 8, + animateAvatars + ) + composeAvatar.contentDescription = getString(R.string.compose_active_account_description, + activeAccount.fullName) + } + + private fun replaceTextAtCaret(text: CharSequence) { + // If you select "backward" in an editable, you get SelectionStart > SelectionEnd + val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd) + val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd) + val textToInsert = if (start > 0 && !composeEditField.text[start - 1].isWhitespace()) { + " $text" + } else { + text + } + composeEditField.text.replace(start, end, textToInsert) + + // Set the cursor after the inserted text + composeEditField.setSelection(start + text.length) + } + + private fun enableFormattingSyntaxButton(syntax: String, enable: Boolean) { + val stringId = when(syntax) { + "text/html" -> R.string.action_html + "text/bbcode" -> R.string.action_bbcode + else -> R.string.action_markdown + } + + val actionStringId = if(enable) R.string.action_disable_formatting_syntax else R.string.action_enable_formatting_syntax + val tooltipText = getString(actionStringId).format(stringId) + + composeFormattingSyntax.contentDescription = tooltipText + + @ColorInt val color = ThemeUtils.getColor(this, if(enable) R.attr.colorPrimary else android.R.attr.textColorTertiary); + composeFormattingSyntax.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN); + + enableMarkdownWYSIWYGButtons(enable); + } + + private fun setIconForSyntax(syntax: String, enable: Boolean) { + val drawableId = when(syntax) { + "text/html" -> R.drawable.ic_html_24dp + "text/bbcode" -> R.drawable.ic_bbcode_24dp + else -> R.drawable.ic_markdown + } + + suggestFormattingSyntax = if(drawableId == R.drawable.ic_markdown) "text/markdown" else syntax + composeFormattingSyntax.setImageResource(drawableId) + enableFormattingSyntaxButton(syntax, enable) + } + + private fun toggleFormattingMode() { + if(viewModel.formattingSyntax.value!! == suggestFormattingSyntax) { + viewModel.formattingSyntax.value = "" + } else { + viewModel.formattingSyntax.value = suggestFormattingSyntax + } + } + + private fun selectFormattingSyntax() : Boolean { + val menu = PopupMenu(this, composeFormattingSyntax) + val plaintextId = 0 + val markdownId = 1 + val bbcodeId = 2 + val htmlId = 3 + menu.menu.add(0, plaintextId, 0, R.string.action_plaintext) + if(viewModel.instanceMetadata.value?.supportsMarkdown ?: false) + menu.menu.add(0, markdownId, 0, R.string.action_markdown) + + if(viewModel.instanceMetadata.value?.supportsBBcode ?: false) + menu.menu.add(0, bbcodeId, 0, R.string.action_bbcode) + + if(viewModel.instanceMetadata.value?.supportsHTML ?: false) + menu.menu.add(0, htmlId, 0, R.string.action_html) + + menu.setOnMenuItemClickListener { menuItem -> + val choose = when (menuItem.itemId) { + markdownId -> "text/markdown" + bbcodeId -> "text/bbcode" + htmlId -> "text/html" + else -> "" + } + suggestFormattingSyntax = choose + viewModel.formattingSyntax.value = choose + true + } + menu.show() + + return true + } + + private fun enableMarkdownWYSIWYGButtons(visible: Boolean) { + val visibility = if(visible) View.VISIBLE else View.GONE + codeButton.visibility = visibility + linkButton.visibility = visibility + strikethroughButton.visibility = visibility + italicButton.visibility = visibility + boldButton.visibility = visibility + } + + fun prependSelectedWordsWith(text: CharSequence) { + // If you select "backward" in an editable, you get SelectionStart > SelectionEnd + val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd) + val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd) + val editorText = composeEditField.text + + if (start == end) { + // No selection, just insert text at caret + editorText.insert(start, text) + // Set the cursor after the inserted text + composeEditField.setSelection(start + text.length) + } else { + var wasWord: Boolean + var isWord = end < editorText.length && !Character.isWhitespace(editorText[end]) + var newEnd = end + + // Iterate the selection backward so we don't have to juggle indices on insertion + var index = end - 1 + while (index >= start - 1 && index >= 0) { + wasWord = isWord + isWord = !Character.isWhitespace(editorText[index]) + if (wasWord && !isWord) { + // We've reached the beginning of a word, perform insert + editorText.insert(index + 1, text) + newEnd += text.length + } + --index + } + + if (start == 0 && isWord) { + // Special case when the selection includes the start of the text + editorText.insert(0, text) + newEnd += text.length + } + + // Keep the same text (including insertions) selected + composeEditField.setSelection(start, newEnd) + } + } + + + private fun atButtonClicked() { + prependSelectedWordsWith("@") + } + + private fun hashButtonClicked() { + prependSelectedWordsWith("#") + } + + private fun codeButtonClicked() { + when(viewModel.formattingSyntax.value!!) { + "text/markdown" -> MarkdownEdit.addCode(composeEditField) + "text/bbcode" -> BBCodeEdit.addCode(composeEditField) + "text/html" -> HTMLEdit.addCode(composeEditField) + } + } + + private fun linkButtonClicked() { + when(viewModel.formattingSyntax.value!!) { + "text/markdown" -> MarkdownEdit.addLink(composeEditField) + "text/bbcode" -> BBCodeEdit.addLink(composeEditField) + "text/html" -> HTMLEdit.addLink(composeEditField) + } + } + + private fun strikethroughButtonClicked() { + when(viewModel.formattingSyntax.value!!) { + "text/markdown" -> MarkdownEdit.addStrikeThrough(composeEditField) + "text/bbcode" -> BBCodeEdit.addStrikeThrough(composeEditField) + "text/html" -> HTMLEdit.addStrikeThrough(composeEditField) + } + } + + private fun italicButtonClicked() { + when(viewModel.formattingSyntax.value!!) { + "text/markdown" -> MarkdownEdit.addItalic(composeEditField) + "text/bbcode" -> BBCodeEdit.addItalic(composeEditField) + "text/html" -> HTMLEdit.addItalic(composeEditField) + } + } + + private fun boldButtonClicked() { + when(viewModel.formattingSyntax.value!!) { + "text/markdown" -> MarkdownEdit.addBold(composeEditField) + "text/bbcode" -> BBCodeEdit.addBold(composeEditField) + "text/html" -> HTMLEdit.addBold(composeEditField) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri) + super.onSaveInstanceState(outState) + } + + private fun displayTransientError(@StringRes stringId: Int) { + val bar = Snackbar.make(activityCompose, stringId, Snackbar.LENGTH_LONG) + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + + private fun toggleHideMedia() { + this.viewModel.toggleMarkSensitive() + } + + private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { + if (viewModel.media.value.isNullOrEmpty()) { + composeHideMediaButton.hide() + } else { + composeHideMediaButton.show() + @ColorInt val color = if (contentWarningShown) { + composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) + composeHideMediaButton.isClickable = false + ContextCompat.getColor(this, R.color.transparent_tusky_blue) + + } else { + composeHideMediaButton.isClickable = true + if (markMediaSensitive) { + composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) + ContextCompat.getColor(this, R.color.tusky_blue) + } else { + composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } + } + composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + } + + private fun updateScheduleButton() { + @ColorInt val color = if (composeScheduleView.time == null) { + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } else { + ContextCompat.getColor(this, R.color.tusky_blue) + } + composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + + private fun enableButtons(enable: Boolean) { + composeAddMediaButton.isClickable = enable + composeToggleVisibilityButton.isClickable = enable + composeEmojiButton.isClickable = enable + composeHideMediaButton.isClickable = enable + composeScheduleButton.isClickable = enable + composeFormattingSyntax.isClickable = enable + composeTootButton.isEnabled = enable + composePreviewButton.isEnabled = enable + composeStickerButton.isEnabled = enable + } + + private fun setStatusVisibility(visibility: Status.Visibility) { + composeOptionsBottomSheet.setStatusVisibility(visibility) + composeTootButton.setStatusVisibility(visibility) + + val iconRes = when (visibility) { + Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp + Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp + Status.Visibility.DIRECT -> R.drawable.ic_email_24dp + Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp + else -> R.drawable.ic_lock_open_24dp + } + composeToggleVisibilityButton.setImageResource(iconRes) + } + + private fun showComposeOptions() { + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + + private fun onScheduleClick() { + if (viewModel.scheduledAt.value == null) { + composeScheduleView.openPickDateDialog() + } else { + showScheduleView() + } + } + + private fun showScheduleView() { + if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + + private fun showEmojis() { + emojiView.adapter?.let { + if (it.itemCount == 0) { + val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } else { + if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + } + } + + private fun openPickDialog() { + if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + + private fun onMediaPick() { + addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + //Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + } else { + initiateMediaPicking() + } + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } + ) + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + private fun openPollDialog() { + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + val instanceParams = viewModel.instanceParams.value!! + showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, + instanceParams.pollMaxLength, viewModel::updatePoll) + } + + private fun setupPollView() { + val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + + val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + layoutParams.setMargins(margin, margin, margin, marginBottom) + pollPreview.layoutParams = layoutParams + + pollPreview.setOnClickListener { + val popup = PopupMenu(this, pollPreview) + val editId = 1 + val removeId = 2 + popup.menu.add(0, editId, 0, R.string.edit_poll) + popup.menu.add(0, removeId, 0, R.string.action_remove) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + editId -> openPollDialog() + removeId -> removePoll() + } + true + } + popup.show() + } + } + + private fun removePoll() { + viewModel.poll.value = null + pollPreview.hide() + } + + override fun onVisibilityChanged(visibility: Status.Visibility) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + viewModel.statusVisibility.value = visibility + } + + @VisibleForTesting + fun calculateTextLength(): Int { + var offset = 0 + val urlSpans = composeEditField.urls + if (urlSpans != null) { + for (span in urlSpans) { + offset += max(0, span.url.length - MAXIMUM_URL_LENGTH) + } + } + var length = composeEditField.length() - offset + if (viewModel.showContentWarning.value!!) { + length += composeContentWarningField.length() + } + return length + } + + private fun updateVisibleCharactersLeft() { + val remainingLength = maximumTootCharacters - calculateTextLength(); + composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) + + val textColor = if (remainingLength < 0) { + ContextCompat.getColor(this, R.color.tusky_red) + } else { + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } + composeCharactersLeftView.setTextColor(textColor) + } + + private fun onContentWarningChanged() { + val showWarning = composeContentWarningBar.isGone + viewModel.contentWarningChanged(showWarning) + updateVisibleCharactersLeft() + } + + private fun verifyScheduledTime(): Boolean { + return composeScheduleView.verifyScheduledTime(composeScheduleView.getDateTime(viewModel.scheduledAt.value)) + } + + private fun onSendClicked(preview: Boolean) { + if(preview && previewBehavior.state != BottomSheetBehavior.STATE_HIDDEN) { + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + if (verifyScheduledTime()) { + sendStatus(preview) + } else { + showScheduleView() + } + } + + private fun onStatusPreviewReady(status: Status) { + enableButtons(true) + previewView.setupWithStatus(status) + previewBehavior.state = BottomSheetBehavior.STATE_EXPANDED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + /** This is for the fancy keyboards which can insert images and stuff. */ + override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean { + // Verify the returned content's type is of the correct MIME type + val supported = inputContentInfo.description.hasMimeType("image/*") + + if (supported) { + val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 + if (lacksPermission) { + try { + inputContentInfo.requestPermission() + } catch (e: Exception) { + Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message) + return false + } + } + pickMedia(inputContentInfo.contentUri, inputContentInfo) + return true + } + + return false + } + + private fun sendStatus(preview: Boolean) { + enableButtons(false) + val contentText = composeEditField.text.toString() + var spoilerText = "" + if (viewModel.showContentWarning.value!!) { + spoilerText = composeContentWarningField.text.toString() + } + val characterCount = calculateTextLength() + if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) { + composeEditField.error = getString(R.string.error_empty) + enableButtons(true) + } else if (characterCount <= maximumTootCharacters) { + if (viewModel.media.value!!.isNotEmpty()) { + finishingUploadDialog = ProgressDialog.show( + this, getString(R.string.dialog_title_finishing_media_upload), + getString(R.string.dialog_message_uploading_media), true, true) + } + + viewModel.sendStatus(contentText, spoilerText, preview).observeOnce(this) { + finishingUploadDialog?.dismiss() + if(!preview) + deleteDraftAndFinish() + } + + } else { + composeEditField.error = getString(R.string.error_compose_character_limit) + enableButtons(true) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, + grantResults: IntArray) { + if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initiateMediaPicking() + } else { + val bar = Snackbar.make(activityCompose, R.string.error_media_upload_permission, + Snackbar.LENGTH_SHORT).apply { + + } + bar.setAction(R.string.action_retry) { onMediaPick() } + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + } + } + + private fun initiateCameraApp() { + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + + // We don't need to ask for permission in this case, because the used calls require + // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was + // way before permission dialogues have been introduced. + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (intent.resolveActivity(packageManager) != null) { + val photoFile: File = try { + createNewImageFile(this) + } catch (ex: IOException) { + displayTransientError(R.string.error_media_upload_opening) + return + } + + // Continue only if the File was successfully created + photoUploadUri = FileProvider.getUriForFile(this, + BuildConfig.APPLICATION_ID + ".fileprovider", + photoFile) + intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) + startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) + } + } + + private fun initiateMediaPicking() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + + if(!viewModel.hasNoAttachmentLimits) { + val mimeTypes = arrayOf("image/*", "video/*", "audio/*") + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + startActivityForResult(intent, MEDIA_PICK_RESULT) + } + + private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { + button.isEnabled = clickable + ThemeUtils.setDrawableTint(this, button.drawable, + if (colorActive) android.R.attr.textColorTertiary + else R.attr.textColorDisabled) + } + + private fun enablePollButton(enable: Boolean) { + addPollTextActionTextView.isEnabled = enable + val textColor = ThemeUtils.getColor(this, + if (enable) android.R.attr.textColorTertiary + else R.attr.textColorDisabled) + addPollTextActionTextView.setTextColor(textColor) + addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) + } + + private fun removeMediaFromQueue(item: QueuedMedia) { + viewModel.removeMediaFromQueue(item) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { + if (intent.data != null) { + // Single media, upload it and done. + pickMedia(intent.data!!) + } else if (intent.clipData != null) { + val clipData = intent.clipData!! + val count = clipData.itemCount + if (mediaCount + count > maxUploadMediaNumber) { + // check if exist media + upcoming media > 4, then prob error message. + Toast.makeText(this, getString(R.string.error_upload_max_media_reached, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() + } else { + // if not grater then 4, upload all multiple media. + for (i in 0 until count) { + val imageUri = clipData.getItemAt(i).getUri() + pickMedia(imageUri) + } + } + } + } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { + pickMedia(photoUploadUri!!) + } + } + + private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) { + withLifecycleContext { + viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem -> + + contentInfoCompat?.releasePermission() + + exceptionOrItem.asLeftOrNull()?.let { + val errorId = when (it) { + is VideoSizeException -> { + R.string.error_video_upload_size + } + is MediaSizeException -> { + R.string.error_media_upload_size + } + is AudioSizeException -> { + R.string.error_audio_upload_size + } + is VideoOrImageException -> { + R.string.error_media_upload_image_or_video + } + else -> { + Log.d(TAG, "That file could not be opened", it) + R.string.error_media_upload_opening + } + } + displayTransientError(errorId) + } + + } + } + } + + private fun showContentWarning(show: Boolean) { + TransitionManager.beginDelayedTransition(composeContentWarningBar.parent as ViewGroup) + @ColorInt val color = if (show) { + composeContentWarningBar.show() + composeContentWarningField.setSelection(composeContentWarningField.text.length) + composeContentWarningField.requestFocus() + ContextCompat.getColor(this, R.color.tusky_blue) + } else { + composeContentWarningBar.hide() + composeEditField.requestFocus() + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } + composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + handleCloseButton() + return true + } + + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + // Acting like a teen: deliberately ignoring parent. + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + stickerBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + previewBehavior.state == BottomSheetBehavior.STATE_HIDDEN) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return + } + + handleCloseButton() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + Log.d(TAG, event.toString()) + if (event.action == KeyEvent.ACTION_DOWN) { + if (event.isCtrlPressed) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // send toot by pressing CTRL + ENTER + this.onSendClicked(false) + return true + } + } + + if (keyCode == KeyEvent.KEYCODE_BACK) { + onBackPressed() + return true + } + } + return super.onKeyDown(keyCode, event) + } + + private fun handleCloseButton() { + val contentText = composeEditField.text.toString() + val contentWarning = composeContentWarningField.text.toString() + if (viewModel.didChange(contentText, contentWarning)) { + AlertDialog.Builder(this) + .setMessage(R.string.compose_save_draft) + .setPositiveButton(R.string.action_save) { _, _ -> + saveDraftAndFinish(contentText, contentWarning) + } + .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } + .show() + } else { + finishWithoutSlideOutAnimation() + } + } + + private fun deleteDraftAndFinish() { + viewModel.deleteDraft() + finishWithoutSlideOutAnimation() + } + + private fun saveDraftAndFinish(contentText: String, contentWarning: String) { + viewModel.saveDraft(contentText, contentWarning) + finishWithoutSlideOutAnimation() + } + + override fun search(token: String): List { + return viewModel.searchAutocompleteSuggestions(token) + } + + override fun onEmojiSelected(shortcode: String) { + replaceTextAtCaret(":$shortcode: ") + } + + private fun setEmojiList(emojiList: List?) { + if (emojiList != null) { + emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity) + enableButton(composeEmojiButton, true, emojiList.isNotEmpty()) + } + } + + private fun showStickers() { + if (stickerBehavior.state == BottomSheetBehavior.STATE_HIDDEN || stickerBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + stickerBehavior.state = BottomSheetBehavior.STATE_EXPANDED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + previewBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + + override fun onEmojiSelected(id: String, shortcode: String) { + // pickMedia(Uri.parse(shortcode)) + + Glide.with(this).asFile().load(shortcode).into( object : CustomTarget() { + override fun onLoadCleared(placeholder: Drawable?) { + displayTransientError(R.string.error_sticker_fetch) + } + + override fun onResourceReady(resource: File, transition: Transition?) { + val cut = shortcode.lastIndexOf('/') + val filename = if(cut != -1) shortcode.substring(cut + 1) else "unknown.png" + pickMedia(resource.toUri(), null, filename) + } + }) + stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + data class QueuedMedia( + val localId: Long, + val uri: Uri, + val type: Int, + val mediaSize: Long, + val originalFileName: String, + val noChanges: Boolean = false, + val anonymizeFileName: Boolean = false, + val uploadPercent: Int = 0, + val id: String? = null, + val description: String? = null + ) { + companion object Type { + public const val IMAGE: Int = 0 + public const val VIDEO: Int = 1 + public const val AUDIO: Int = 2 + public const val UNKNOWN: Int = 3 + } + } + + override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) { + composeScheduleView.onTimeSet(hourOfDay, minute) + viewModel.updateScheduledAt(composeScheduleView.time) + if (verifyScheduledTime()) { + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + showScheduleView() + } + } + + private fun resetSchedule() { + viewModel.updateScheduledAt(null) + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + @Parcelize + data class ComposeOptions( + // Let's keep fields var until all consumers are Kotlin + var scheduledTootId: String? = null, + var savedTootUid: Int? = null, + var draftId: Int? = null, + var tootText: String? = null, + var mediaUrls: List? = null, + var mediaDescriptions: List? = null, + var mentionedUsernames: Set? = null, + var inReplyToId: String? = null, + var replyVisibility: Status.Visibility? = null, + var visibility: Status.Visibility? = null, + var contentWarning: String? = null, + var replyingStatusAuthor: String? = null, + var replyingStatusContent: String? = null, + var mediaAttachments: List? = null, + var draftAttachments: List? = null, + var scheduledAt: String? = null, + var sensitive: Boolean? = null, + var poll: NewPoll? = null, + var formattingSyntax: String? = null, + var modifiedInitialState: Boolean? = null + ) : Parcelable + + companion object { + private const val TAG = "ComposeActivity" // logging tag + private const val MEDIA_PICK_RESULT = 1 + private const val MEDIA_TAKE_PHOTO_RESULT = 2 + private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 + + internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" + private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" + + // Mastodon only counts URLs as this long in terms of status character limits + @VisibleForTesting + const val MAXIMUM_URL_LENGTH = 23 + + @JvmStatic + fun startIntent(context: Context, options: ComposeOptions): Intent { + return Intent(context, ComposeActivity::class.java).apply { + putExtra(COMPOSE_OPTIONS_EXTRA, options) + } + } + + fun canHandleMimeType(mimeType: String?): Boolean { + return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain") + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java new file mode 100644 index 0000000..09d7068 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java @@ -0,0 +1,320 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose; + +import android.content.Context; +import android.preference.PreferenceManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.ImageView; +import android.widget.TextView; + +import com.bumptech.glide.Glide; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.HashTag; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Created by charlag on 12/11/17. + */ + +public class ComposeAutoCompleteAdapter extends BaseAdapter + implements Filterable { + private static final int ACCOUNT_VIEW_TYPE = 1; + private static final int HASHTAG_VIEW_TYPE = 2; + private static final int EMOJI_VIEW_TYPE = 3; + private static final int SEPARATOR_VIEW_TYPE = 0; + + private final ArrayList resultList; + private final AutocompletionProvider autocompletionProvider; + + public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider) { + super(); + resultList = new ArrayList<>(); + this.autocompletionProvider = autocompletionProvider; + } + + @Override + public int getCount() { + return resultList.size(); + } + + @Override + public AutocompleteResult getItem(int index) { + return resultList.get(index); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + @NonNull + public Filter getFilter() { + return new Filter() { + @Override + public CharSequence convertResultToString(Object resultValue) { + if (resultValue instanceof AccountResult) { + return formatUsername(((AccountResult) resultValue)); + } else if (resultValue instanceof HashtagResult) { + return formatHashtag((HashtagResult) resultValue); + } else if (resultValue instanceof EmojiResult) { + return formatEmoji((EmojiResult) resultValue); + } else { + return ""; + } + } + + // This method is invoked in a worker thread. + @Override + protected FilterResults performFiltering(CharSequence constraint) { + FilterResults filterResults = new FilterResults(); + if (constraint != null) { + List results = + autocompletionProvider.search(constraint.toString()); + filterResults.values = results; + filterResults.count = results.size(); + } + return filterResults; + } + + @SuppressWarnings("unchecked") + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + if (results != null && results.count > 0) { + resultList.clear(); + resultList.addAll((List) results.values); + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + }; + } + + @Override + @NonNull + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + View view = convertView; + final Context context = parent.getContext(); + + switch (getItemViewType(position)) { + case ACCOUNT_VIEW_TYPE: + AccountViewHolder accountViewHolder; + if (convertView == null) { + view = ((LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.item_autocomplete_account, parent, false); + } + if (view.getTag() == null) { + view.setTag(new AccountViewHolder(view)); + } + accountViewHolder = (AccountViewHolder) view.getTag(); + + AccountResult accountResult = ((AccountResult) getItem(position)); + if (accountResult != null) { + Account account = accountResult.account; + String formattedUsername = context.getString( + R.string.status_username_format, + account.getUsername() + ); + accountViewHolder.username.setText(formattedUsername); + CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), + account.getEmojis(), accountViewHolder.displayName); + accountViewHolder.displayName.setText(emojifiedName); + + int avatarRadius = accountViewHolder.avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_42dp); + + boolean animateAvatar = PreferenceManager.getDefaultSharedPreferences(accountViewHolder.avatar.getContext()) + .getBoolean("animateGifAvatars", false); + + ImageLoadingHelper.loadAvatar( + account.getAvatar(), + accountViewHolder.avatar, + avatarRadius, + animateAvatar + ); + } + break; + + case HASHTAG_VIEW_TYPE: + if (convertView == null) { + view = ((LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.item_autocomplete_hashtag, parent, false); + } + + HashtagResult result = (HashtagResult) getItem(position); + if (result != null) { + ((TextView) view).setText(formatHashtag(result)); + } + break; + + case EMOJI_VIEW_TYPE: + EmojiViewHolder emojiViewHolder; + if (convertView == null) { + view = ((LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.item_autocomplete_emoji, parent, false); + } + if (view.getTag() == null) { + view.setTag(new EmojiViewHolder(view)); + } + emojiViewHolder = (EmojiViewHolder) view.getTag(); + + EmojiResult emojiResult = ((EmojiResult) getItem(position)); + if (emojiResult != null) { + Emoji emoji = emojiResult.emoji; + String formattedShortcode = context.getString( + R.string.emoji_shortcode_format, + emoji.getShortcode() + ); + emojiViewHolder.shortcode.setText(formattedShortcode); + Glide.with(emojiViewHolder.preview) + .load(emoji.getUrl()) + .into(emojiViewHolder.preview); + } + break; + + case SEPARATOR_VIEW_TYPE: + if (convertView == null) { + view = ((LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.item_autocomplete_divider, parent, false); + } + break; + default: + throw new AssertionError("unknown view type"); + } + + return view; + } + + private static String formatUsername(AccountResult result) { + return String.format("@%s", result.account.getUsername()); + } + + private static String formatHashtag(HashtagResult result) { + return String.format("#%s", result.hashtag); + } + + private static String formatEmoji(EmojiResult result) { + return String.format(":%s:", result.emoji.getShortcode()); + } + + @Override + public int getViewTypeCount() { + return 4; + } + + @Override + public int getItemViewType(int position) { + AutocompleteResult item = getItem(position); + + if (item instanceof AccountResult) { + return ACCOUNT_VIEW_TYPE; + } else if (item instanceof HashtagResult) { + return HASHTAG_VIEW_TYPE; + } else if (item instanceof EmojiResult) { + return EMOJI_VIEW_TYPE; + } else { + return SEPARATOR_VIEW_TYPE; + } + } + + @Override + public boolean areAllItemsEnabled() { + // there may be separators + return false; + } + + @Override + public boolean isEnabled(int position) { + return !(getItem(position) instanceof ResultSeparator); + } + + public abstract static class AutocompleteResult { + AutocompleteResult() { + } + } + + public final static class AccountResult extends AutocompleteResult { + public final Account account; + + public AccountResult(Account account) { + this.account = account; + } + } + + public final static class HashtagResult extends AutocompleteResult { + private final String hashtag; + + public HashtagResult(HashTag hashtag) { + this.hashtag = hashtag.getName(); + } + } + + public final static class EmojiResult extends AutocompleteResult { + private final Emoji emoji; + + public EmojiResult(Emoji emoji) { + this.emoji = emoji; + } + } + + public final static class ResultSeparator extends AutocompleteResult {} + + public interface AutocompletionProvider { + List search(String mention); + } + + private class AccountViewHolder { + final TextView username; + final TextView displayName; + final ImageView avatar; + + private AccountViewHolder(View view) { + username = view.findViewById(R.id.username); + displayName = view.findViewById(R.id.display_name); + avatar = view.findViewById(R.id.avatar); + } + } + + private class EmojiViewHolder { + final TextView shortcode; + final ImageView preview; + + private EmojiViewHolder(View view) { + shortcode = view.findViewById(R.id.shortcode); + preview = view.findViewById(R.id.preview); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt new file mode 100644 index 0000000..bfcfa93 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -0,0 +1,295 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import com.keylesspalace.tusky.components.common.CommonComposeViewModel +import com.keylesspalace.tusky.components.common.MediaUploader +import com.keylesspalace.tusky.components.common.mutableLiveData +import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.drafts.DraftHelper +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.service.TootToSend +import com.keylesspalace.tusky.util.* +import io.reactivex.Observable.just +import java.util.* +import javax.inject.Inject + +class ComposeViewModel @Inject constructor( + private val api: MastodonApi, + private val accountManager: AccountManager, + private val mediaUploader: MediaUploader, + private val serviceClient: ServiceClient, + private val draftHelper: DraftHelper, + private val saveTootHelper: SaveTootHelper, + private val db: AppDatabase +) : CommonComposeViewModel(api, accountManager, mediaUploader, db) { + + private var replyingStatusAuthor: String? = null + private var replyingStatusContent: String? = null + internal var startingText: String? = null + private var savedTootUid: Int = 0 + private var draftId: Int = 0 + private var scheduledTootId: String? = null + private var startingContentWarning: String = "" + private var inReplyToId: String? = null + private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN + private var contentWarningStateChanged: Boolean = false + private var modifiedInitialState: Boolean = false + + val markMediaAsSensitive = + mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + + val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) + val showContentWarning = mutableLiveData(false) + val setupComplete = mutableLiveData(false) + val poll: MutableLiveData = mutableLiveData(null) + val scheduledAt: MutableLiveData = mutableLiveData(null) + val formattingSyntax: MutableLiveData = mutableLiveData("") + + private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() + + fun toggleMarkSensitive() { + this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true + } + + fun didChange(content: String?, contentWarning: String?): Boolean { + + val textChanged = !(content.isNullOrEmpty() + || startingText?.startsWith(content.toString()) ?: false) + + val contentWarningChanged = showContentWarning.value!! + && !contentWarning.isNullOrEmpty() + && !startingContentWarning.startsWith(contentWarning.toString()) + val mediaChanged = !media.value.isNullOrEmpty() + val pollChanged = poll.value != null + + return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged + } + + fun contentWarningChanged(value: Boolean) { + showContentWarning.value = value + contentWarningStateChanged = true + } + + fun deleteDraft() { + if (savedTootUid != 0) { + saveTootHelper.deleteDraft(savedTootUid) + } + if (draftId != 0) { + draftHelper.deleteDraftAndAttachments(draftId) + .subscribe() + } + } + + fun saveDraft(content: String, contentWarning: String) { + + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + media.value?.forEach { item -> + mediaUris.add(item.uri.toString()) + mediaDescriptions.add(item.description) + } + draftHelper.saveDraft( + draftId = draftId, + accountId = accountManager.activeAccount?.id!!, + inReplyToId = inReplyToId, + content = content, + contentWarning = contentWarning, + sensitive = markMediaAsSensitive.value!!, + visibility = statusVisibility.value!!, + mediaUris = mediaUris, + mediaDescriptions = mediaDescriptions, + poll = poll.value, + formattingSyntax = formattingSyntax.value!!, + failedToSend = false + ).subscribe() + } + + /** + * Send status to the server. + * Uses current state plus provided arguments. + * @return LiveData which will signal once the screen can be closed or null if there are errors + */ + fun sendStatus( + content: String, + spoilerText: String, + preview: Boolean + ): LiveData { + + val deletionObservable = if (isEditingScheduledToot) { + api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } + } else { + just(Unit) + }.toLiveData() + + val sendObservable = media + .filter { items -> items.all { it.uploadPercent == -1 } } + .map { + val mediaIds = ArrayList() + val mediaUris = ArrayList() + val mediaDescriptions = ArrayList() + for (item in media.value!!) { + mediaIds.add(item.id!!) + mediaUris.add(item.uri) + mediaDescriptions.add(item.description ?: "") + } + + val tootToSend = TootToSend( + text = content, + warningText = spoilerText, + visibility = statusVisibility.value!!.serverString(), + sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), + mediaIds = mediaIds, + mediaUris = mediaUris.map { it.toString() }, + mediaDescriptions = mediaDescriptions, + scheduledAt = scheduledAt.value, + inReplyToId = inReplyToId, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + formattingSyntax = formattingSyntax.value!!, + preview = preview, + accountId = accountManager.activeAccount!!.id, + savedTootUid = savedTootUid, + draftId = draftId, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) + + serviceClient.sendToot(tootToSend) + } + + return combineLiveData(deletionObservable, sendObservable) { _, _ -> } + } + + fun setup(composeOptions: ComposeActivity.ComposeOptions?) { + + if (setupComplete.value == true) { + return + } + + val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy + + val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN + startingVisibility = Status.Visibility.byNum( + preferredVisibility.num.coerceAtLeast(replyVisibility.num)) + + inReplyToId = composeOptions?.inReplyToId + + modifiedInitialState = composeOptions?.modifiedInitialState == true + + val contentWarning = composeOptions?.contentWarning + if (contentWarning != null) { + startingContentWarning = contentWarning + } + if (!contentWarningStateChanged) { + showContentWarning.value = !contentWarning.isNullOrBlank() + } + + // recreate media list + val loadedDraftMediaUris = composeOptions?.mediaUrls + val loadedDraftMediaDescriptions: List? = composeOptions?.mediaDescriptions + val draftAttachments = composeOptions?.draftAttachments + if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { + // when coming from SavedTootActivity + loadedDraftMediaUris.zip(loadedDraftMediaDescriptions) + .forEach { (uri, description) -> + pickMedia(uri.toUri(), null).observeForever { errorOrItem -> + if (errorOrItem.isRight() && description != null) { + updateDescription(errorOrItem.asRight().localId, description) + } + } + } + } else if (draftAttachments != null) { + // when coming from DraftActivity + draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } + } else composeOptions?.mediaAttachments?.forEach { a -> + // when coming from redraft or ScheduledTootActivity + val mediaType = when (a.type) { + Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO + Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE + Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO + } + addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) + } + + savedTootUid = composeOptions?.savedTootUid ?: 0 + draftId = composeOptions?.draftId ?: 0 + scheduledTootId = composeOptions?.scheduledTootId + startingText = composeOptions?.tootText + + val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN + if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { + startingVisibility = tootVisibility + } + statusVisibility.value = startingVisibility + val mentionedUsernames = composeOptions?.mentionedUsernames + if (mentionedUsernames != null) { + val builder = StringBuilder() + for (name in mentionedUsernames) { + builder.append('@') + builder.append(name) + builder.append(' ') + } + startingText = builder.toString() + } + + scheduledAt.value = composeOptions?.scheduledAt + + composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } + + val poll = composeOptions?.poll + if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { + this.poll.value = poll + } + replyingStatusContent = composeOptions?.replyingStatusContent + replyingStatusAuthor = composeOptions?.replyingStatusAuthor + + formattingSyntax.value = composeOptions?.formattingSyntax ?: accountManager.activeAccount!!.defaultFormattingSyntax + } + + fun updatePoll(newPoll: NewPoll) { + poll.value = newPoll + } + + fun updateScheduledAt(newScheduledAt: String?) { + scheduledAt.value = newScheduledAt + } + + override fun onCleared() { + for (uploadDisposable in mediaToDisposable.values) { + uploadDisposable.dispose() + } + super.onCleared() + } + + private companion object { + const val TAG = "ComposeViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt new file mode 100644 index 0000000..f49a05e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -0,0 +1,159 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.PopupMenu +import android.view.Gravity +import android.text.TextUtils +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.view.ProgressTextView +import com.keylesspalace.tusky.components.compose.view.ProgressImageView + +class MediaPreviewAdapter( + context: Context, + private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, + private val onRemove: (ComposeActivity.QueuedMedia) -> Unit +) : RecyclerView.Adapter() { + + fun submitList(list: List) { + this.differ.submitList(list) + } + + private fun onMediaClick(position: Int, view: View) { + val item = differ.currentList[position] + val popup = PopupMenu(view.context, view) + val addCaptionId = 1 + val removeId = 2 + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + popup.menu.add(0, removeId, 0, R.string.action_remove) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + addCaptionId -> onAddCaption(item) + removeId -> onRemove(item) + } + true + } + popup.show() + } + + private val thumbnailViewSize = + context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + + override fun getItemCount(): Int = differ.currentList.size + + override fun getItemViewType(position: Int): Int { + val item = differ.currentList[position] + return item.type + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + when(viewType) { + ComposeActivity.QueuedMedia.Type.UNKNOWN -> { + return TextViewHolder(ProgressTextView(parent.context)) + } + else -> { + return PreviewViewHolder(ProgressImageView(parent.context)) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = differ.currentList[position] + + when(item.type) { + ComposeActivity.QueuedMedia.Type.UNKNOWN -> { + (holder as TextViewHolder).view.setText(item.originalFileName) + holder.view.setChecked(!item.description.isNullOrEmpty()) + holder.view.setProgress(item.uploadPercent) + } + ComposeActivity.QueuedMedia.Type.AUDIO -> { + (holder as PreviewViewHolder).view.setChecked(!item.description.isNullOrEmpty()) + holder.view.setProgress(item.uploadPercent) + holder.view.setImageResource(R.drawable.ic_music_box_preview_24dp) + } + else -> { + (holder as PreviewViewHolder).view.setChecked(!item.description.isNullOrEmpty()) + holder.view.setProgress(item.uploadPercent) + + Glide.with(holder.itemView.context) + .load(item.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(holder.view) + } + } + } + + private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + return oldItem.localId == newItem.localId + } + + override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + return oldItem == newItem + } + }) + + inner class TextViewHolder(val view: ProgressTextView) + : RecyclerView.ViewHolder(view) { + init { + val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) + val margin = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + layoutParams.setMargins(margin, 0, margin, marginBottom) + view.layoutParams = layoutParams + view.gravity = Gravity.CENTER + view.setHorizontallyScrolling(true) + view.ellipsize = TextUtils.TruncateAt.MARQUEE + view.marqueeRepeatLimit = -1 + view.setSingleLine() + view.setSelected(true) + view.textSize = 16.0f + view.setOnClickListener { + onMediaClick(adapterPosition, view) + } + } + } + + inner class PreviewViewHolder(val view: ProgressImageView) + : RecyclerView.ViewHolder(view) { + init { + val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) + val margin = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + layoutParams.setMargins(margin, 0, margin, marginBottom) + view.layoutParams = layoutParams + view.scaleType = ImageView.ScaleType.CENTER_CROP + view.setOnClickListener { + onMediaClick(adapterPosition, view) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt new file mode 100644 index 0000000..d0f98ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -0,0 +1,101 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +@file:JvmName("AddPollDialog") + +package com.keylesspalace.tusky.components.compose.dialog + +import android.content.Context +import android.view.LayoutInflater +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter +import com.keylesspalace.tusky.entity.NewPoll +import kotlinx.android.synthetic.main.dialog_add_poll.view.* + +fun showAddPollDialog( + context: Context, + poll: NewPoll?, + maxOptionCount: Int, + maxOptionLength: Int, + onUpdatePoll: (NewPoll) -> Unit +) { + + val view = LayoutInflater.from(context).inflate(R.layout.dialog_add_poll, null) + + val dialog = AlertDialog.Builder(context) + .setIcon(R.drawable.ic_poll_24dp) + .setTitle(R.string.create_poll_title) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .create() + + val adapter = AddPollOptionsAdapter( + options = poll?.options?.toMutableList() ?: mutableListOf("", ""), + maxOptionLength = maxOptionLength, + onOptionRemoved = { valid -> + view.addChoiceButton.isEnabled = true + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid + }, + onOptionChanged = { valid -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid + } + ) + + view.pollChoices.adapter = adapter + + view.addChoiceButton.setOnClickListener { + if (adapter.itemCount < maxOptionCount) { + adapter.addChoice() + } + if (adapter.itemCount >= maxOptionCount) { + it.isEnabled = false + } + } + + val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast { + it <= poll?.expiresIn ?: 0 + } + + view.pollDurationSpinner.setSelection(pollDurationId) + + view.multipleChoicesCheckBox.isChecked = poll?.multiple ?: false + + dialog.setOnShowListener { + val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + button.setOnClickListener { + val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition + + val pollDuration = context.resources + .getIntArray(R.array.poll_duration_values)[selectedPollDurationId] + + onUpdatePoll(NewPoll( + options = adapter.pollOptions, + expiresIn = pollDuration, + multiple = view.multipleChoicesCheckBox.isChecked + )) + + dialog.dismiss() + } + } + + dialog.show() + + // make the dialog focusable so the keyboard does not stay behind it + dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt new file mode 100644 index 0000000..9399858 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -0,0 +1,118 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.dialog + +import android.app.Activity +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.InputFilter +import android.text.InputType +import android.util.DisplayMetrics +import android.view.WindowManager +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.github.piasy.biv.loader.glide.GlideCustomImageLoader +import com.github.piasy.biv.view.BigImageView +import com.github.piasy.biv.loader.ImageLoader +import com.github.piasy.biv.view.GlideImageViewFactory +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.withLifecycleContext +import java.io.File + +// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 +private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 + +fun T.makeCaptionDialog(existingDescription: String?, + previewUri: Uri, + onUpdateDescription: (String) -> LiveData +) where T : Activity, T : LifecycleOwner { + val dialogLayout = LinearLayout(this) + val padding = Utils.dpToPx(this, 8) + dialogLayout.setPadding(padding, padding, padding, padding) + + dialogLayout.orientation = LinearLayout.VERTICAL + val imageView = BigImageView(this) + imageView.setImageViewFactory(GlideImageViewFactory()) + imageView.setImageLoaderCallback(object : ImageLoader.Callback { + override fun onSuccess(image: File?) { + imageView.ssiv?.let { it.maxScale = 6f } + } + override fun onFail(error: Exception?) {} + override fun onStart() {} + override fun onCacheHit(imageType: Int, image: File?) {} + override fun onCacheMiss(imageType: Int, image: File?) {} + override fun onFinish() {} + override fun onProgress(progress: Int) {} + }) + imageView.showImage(previewUri) + + val displayMetrics = DisplayMetrics() + windowManager.defaultDisplay.getMetrics(displayMetrics) + + val margin = Utils.dpToPx(this, 4) + dialogLayout.addView(imageView) + (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f + imageView.layoutParams.height = 0 + (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) + + val input = EditText(this) + input.hint = getString(R.string.hint_describe_for_visually_impaired, + MEDIA_DESCRIPTION_CHARACTER_LIMIT) + dialogLayout.addView(input) + (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) + input.setLines(2) + input.inputType = (InputType.TYPE_CLASS_TEXT + or InputType.TYPE_TEXT_FLAG_MULTI_LINE + or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) + input.setText(existingDescription) + input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) + + val okListener = { dialog: DialogInterface, _: Int -> + onUpdateDescription(input.text.toString()) + withLifecycleContext { + onUpdateDescription(input.text.toString()) + .observe { success -> if (!success) showFailedCaptionMessage() } + + } + + dialog.dismiss() + } + + val dialog = AlertDialog.Builder(this) + .setView(dialogLayout) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, null) + .create() + + val window = dialog.window + window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + dialog.show() +} + +private fun Activity.showFailedCaptionMessage() { + Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt new file mode 100644 index 0000000..8f80c76 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt @@ -0,0 +1,70 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.RadioGroup +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Status + +class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) { + + var listener: ComposeOptionsListener? = null + + init { + inflate(context, R.layout.view_compose_options, this) + + setOnCheckedChangeListener { _, checkedId -> + val visibility = when (checkedId) { + R.id.publicRadioButton -> + Status.Visibility.PUBLIC + R.id.unlistedRadioButton -> + Status.Visibility.UNLISTED + R.id.privateRadioButton -> + Status.Visibility.PRIVATE + R.id.directRadioButton -> + Status.Visibility.DIRECT + else -> + Status.Visibility.PUBLIC + } + listener?.onVisibilityChanged(visibility) + } + } + + fun setStatusVisibility(visibility: Status.Visibility) { + val selectedButton = when (visibility) { + Status.Visibility.PUBLIC -> + R.id.publicRadioButton + Status.Visibility.UNLISTED -> + R.id.unlistedRadioButton + Status.Visibility.PRIVATE -> + R.id.privateRadioButton + Status.Visibility.DIRECT -> + R.id.directRadioButton + else -> + R.id.directRadioButton + + } + + check(selectedButton) + } + +} + +interface ComposeOptionsListener { + fun onVisibilityChanged(visibility: Status.Visibility) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java new file mode 100644 index 0000000..a1a99a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java @@ -0,0 +1,228 @@ +/* Copyright 2019 kyori19 + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.AttributeSet; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; + +import com.google.android.material.datepicker.CalendarConstraints; +import com.google.android.material.datepicker.DateValidatorPointForward; +import com.google.android.material.datepicker.MaterialDatePicker; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.fragment.TimePickerFragment; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +public class ComposeScheduleView extends ConstraintLayout { + + private DateFormat dateFormat; + private DateFormat timeFormat; + private SimpleDateFormat iso8601; + + private Button resetScheduleButton; + private TextView scheduledDateTimeView; + private TextView invalidScheduleWarningView; + + private Calendar scheduleDateTime; + public static int MINIMUM_SCHEDULED_SECONDS = 330; // Minimum is 5 minutes, pad 30 seconds for posting + + public ComposeScheduleView(Context context) { + super(context); + init(); + } + + public ComposeScheduleView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ComposeScheduleView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + inflate(getContext(), R.layout.view_compose_schedule, this); + + dateFormat = SimpleDateFormat.getDateInstance(); + timeFormat = SimpleDateFormat.getTimeInstance(); + iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()); + iso8601.setTimeZone(TimeZone.getTimeZone("UTC")); + + resetScheduleButton = findViewById(R.id.resetScheduleButton); + scheduledDateTimeView = findViewById(R.id.scheduledDateTime); + invalidScheduleWarningView = findViewById(R.id.invalidScheduleWarning); + + scheduledDateTimeView.setOnClickListener(v -> openPickDateDialog()); + invalidScheduleWarningView.setText(R.string.warning_scheduling_interval); + + scheduleDateTime = null; + + setScheduledDateTime(); + + setEditIcons(); + } + + private void setScheduledDateTime() { + if (scheduleDateTime == null) { + scheduledDateTimeView.setText(""); + invalidScheduleWarningView.setVisibility(GONE); + } else { + Date scheduled = scheduleDateTime.getTime(); + scheduledDateTimeView.setText(String.format("%s %s", + dateFormat.format(scheduled), + timeFormat.format(scheduled))); + verifyScheduledTime(scheduled); + } + } + + private void setEditIcons() { + Drawable icon = ContextCompat.getDrawable(getContext(), R.drawable.ic_create_24dp); + if (icon == null) { + return; + } + + final int size = scheduledDateTimeView.getLineHeight(); + + icon.setBounds(0, 0, size, size); + + scheduledDateTimeView.setCompoundDrawables(null, null, icon, null); + } + + public void setResetOnClickListener(OnClickListener listener) { + resetScheduleButton.setOnClickListener(listener); + } + + public void resetSchedule() { + scheduleDateTime = null; + setScheduledDateTime(); + } + + public void openPickDateDialog() { + long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000; + CalendarConstraints calendarConstraints = new CalendarConstraints.Builder() + .setValidator( + DateValidatorPointForward.from(yesterday)) + .build(); + initializeSuggestedTime(); + MaterialDatePicker picker = MaterialDatePicker.Builder + .datePicker() + .setSelection(scheduleDateTime.getTimeInMillis()) + .setCalendarConstraints(calendarConstraints) + .build(); + picker.addOnPositiveButtonClickListener(this::onDateSet); + picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "date_picker"); + } + + private void openPickTimeDialog() { + TimePickerFragment picker = new TimePickerFragment(); + if (scheduleDateTime != null) { + Bundle args = new Bundle(); + args.putInt(TimePickerFragment.PICKER_TIME_HOUR, scheduleDateTime.get(Calendar.HOUR_OF_DAY)); + args.putInt(TimePickerFragment.PICKER_TIME_MINUTE, scheduleDateTime.get(Calendar.MINUTE)); + picker.setArguments(args); + } + picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "time_picker"); + } + + public Date getDateTime(String scheduledAt) { + if (scheduledAt != null) { + try { + return iso8601.parse(scheduledAt); + } catch (ParseException e) { + } + } + return null; + } + + public void setDateTime(String scheduledAt) { + Date date; + try { + date = iso8601.parse(scheduledAt); + } catch (ParseException e) { + return; + } + initializeSuggestedTime(); + scheduleDateTime.setTime(date); + setScheduledDateTime(); + } + + public boolean verifyScheduledTime(@Nullable Date scheduledTime) { + boolean valid; + if (scheduledTime != null) { + Calendar minimumScheduledTime = getCalendar(); + minimumScheduledTime.add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS); + valid = scheduledTime.after(minimumScheduledTime.getTime()); + } else { + valid = true; + } + invalidScheduleWarningView.setVisibility(valid ? GONE : VISIBLE); + return valid; + } + + private void onDateSet(long selection) { + initializeSuggestedTime(); + Calendar newDate = getCalendar(); + // working around bug in DatePicker where date is UTC #1720 + // see https://github.com/material-components/material-components-android/issues/882 + newDate.setTimeZone(TimeZone.getTimeZone("UTC")); + newDate.setTimeInMillis(selection); + scheduleDateTime.set(newDate.get(Calendar.YEAR), newDate.get(Calendar.MONTH), newDate.get(Calendar.DATE)); + openPickTimeDialog(); + } + + public void onTimeSet(int hourOfDay, int minute) { + initializeSuggestedTime(); + scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay); + scheduleDateTime.set(Calendar.MINUTE, minute); + setScheduledDateTime(); + } + + public String getTime() { + if (scheduleDateTime == null) { + return null; + } + return iso8601.format(scheduleDateTime.getTime()); + } + + @NonNull + public static Calendar getCalendar() { + return Calendar.getInstance(TimeZone.getDefault()); + } + + private void initializeSuggestedTime() { + if (scheduleDateTime == null) { + scheduleDateTime = getCalendar(); + scheduleDateTime.add(Calendar.MINUTE, 15); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt new file mode 100644 index 0000000..0a5e1c3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt @@ -0,0 +1,65 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view + +import android.content.Context +import androidx.emoji.widget.EmojiEditTextHelper +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView +import android.text.InputType +import android.text.method.KeyListener +import android.util.AttributeSet +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection + +class EditTextTyped @JvmOverloads constructor(context: Context, + attributeSet: AttributeSet? = null) + : AppCompatMultiAutoCompleteTextView(context, attributeSet) { + + private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null + private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) + + init { + //fix a bug with autocomplete and some keyboards + val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) + inputType = newInputType + super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener)) + } + + override fun setKeyListener(input: KeyListener) { + super.setKeyListener(getEmojiEditTextHelper().getKeyListener(input)) + } + + fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) { + onCommitContentListener = listener + } + + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { + val connection = super.onCreateInputConnection(editorInfo) + return if (onCommitContentListener != null) { + EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) + getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo, + onCommitContentListener!!), editorInfo)!! + } else { + connection + } + } + + private fun getEmojiEditTextHelper(): EmojiEditTextHelper { + return emojiEditTextHelper + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt new file mode 100644 index 0000000..63e627f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt @@ -0,0 +1,64 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter +import com.keylesspalace.tusky.entity.NewPoll +import kotlinx.android.synthetic.main.view_poll_preview.view.* + +class PollPreviewView @JvmOverloads constructor( + context: Context?, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) + : LinearLayout(context, attrs, defStyleAttr) { + + val adapter = PreviewPollOptionsAdapter() + + init { + inflate(context, R.layout.view_poll_preview, this) + + orientation = VERTICAL + + setBackgroundResource(R.drawable.card_frame) + + val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding) + + setPadding(padding, padding, padding, padding) + + pollPreviewOptions.adapter = adapter + + } + + fun setPoll(poll: NewPoll){ + adapter.update(poll.options, poll.multiple) + + val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast { + it <= poll.expiresIn + } + pollDurationPreview.text = resources.getStringArray(R.array.poll_duration_names)[pollDurationId] + + } + + override fun setOnClickListener(l: OnClickListener?) { + super.setOnClickListener(l) + adapter.setOnClickListener(l) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java new file mode 100644 index 0000000..0811703 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java @@ -0,0 +1,122 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view; + +import android.content.Context; +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.graphics.drawable.Drawable; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatImageView; +import android.util.AttributeSet; + +import com.keylesspalace.tusky.R; +import at.connyduck.sparkbutton.helpers.Utils; + +public final class ProgressImageView extends AppCompatImageView { + + private int progress = -1; + private final RectF progressRect = new RectF(); + private final RectF biggerRect = new RectF(); + private final Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint clearPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint markBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private Drawable captionDrawable; + + public ProgressImageView(Context context) { + super(context); + init(); + } + + public ProgressImageView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ProgressImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + circlePaint.setColor(ContextCompat.getColor(getContext(), R.color.tusky_blue)); + circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4)); + circlePaint.setStyle(Paint.Style.STROKE); + + clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + + markBgPaint.setStyle(Paint.Style.FILL); + markBgPaint.setColor(ContextCompat.getColor(getContext(), + R.color.tusky_grey_10)); + captionDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.spellcheck); + } + + public void setProgress(int progress) { + this.progress = progress; + if (progress != -1) { + setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY); + } else { + clearColorFilter(); + } + invalidate(); + } + + public void setChecked(boolean checked) { + this.markBgPaint.setColor(ContextCompat.getColor(getContext(), + checked ? R.color.tusky_blue : R.color.tusky_grey_10)); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + float angle = (progress / 100f) * 360 - 90; + float halfWidth = getWidth() / 2.0f; + float halfHeight = getHeight() / 2.0f; + progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f); + biggerRect.set(progressRect); + int margin = 8; + biggerRect.set(progressRect.left - margin, progressRect.top - margin, progressRect.right + margin, progressRect.bottom + margin); + canvas.saveLayer(biggerRect, null, Canvas.ALL_SAVE_FLAG); + if (progress != -1) { + canvas.drawOval(progressRect, circlePaint); + canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint); + } + canvas.restore(); + + int circleRadius = Utils.dpToPx(getContext(), 14); + int circleMargin = Utils.dpToPx(getContext(), 14); + + int circleY = getHeight() - circleMargin - circleRadius / 2; + int circleX = getWidth() - circleMargin - circleRadius / 2; + + canvas.drawCircle(circleX, circleY, circleRadius, markBgPaint); + + captionDrawable.setBounds(getWidth() - circleMargin - circleRadius, + getHeight() - circleMargin - circleRadius, + getWidth() - circleMargin, + getHeight() - circleMargin); + captionDrawable.setTint(Color.WHITE); + captionDrawable.draw(canvas); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressTextView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressTextView.java new file mode 100644 index 0000000..8078f6e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressTextView.java @@ -0,0 +1,121 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view; + +import android.content.Context; +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.graphics.drawable.Drawable; +import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatTextView; +import android.util.AttributeSet; + +import com.keylesspalace.tusky.R; +import at.connyduck.sparkbutton.helpers.Utils; + +public final class ProgressTextView extends TextView { + + private int progress = -1; + private final RectF progressRect = new RectF(); + private final RectF biggerRect = new RectF(); + private final Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint clearPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint markBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private Drawable captionDrawable; + + public ProgressTextView(Context context) { + super(context); + init(); + } + + public ProgressTextView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ProgressTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + circlePaint.setColor(ContextCompat.getColor(getContext(), R.color.tusky_blue)); + circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4)); + circlePaint.setStyle(Paint.Style.STROKE); + + clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + + markBgPaint.setStyle(Paint.Style.FILL); + markBgPaint.setColor(ContextCompat.getColor(getContext(), + R.color.tusky_grey_10)); + captionDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.spellcheck); + } + + public void setProgress(int progress) { + this.progress = progress; + invalidate(); + } + + public void setChecked(boolean checked) { + this.markBgPaint.setColor(ContextCompat.getColor(getContext(), + checked ? R.color.tusky_blue : R.color.tusky_grey_10)); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // https://stackoverflow.com/questions/25501185/ + canvas.translate(getScrollX(), 0); + + float angle = (progress / 100f) * 360 - 90; + float halfWidth = getWidth() / 2.0f; + float halfHeight = getHeight() / 2.0f; + progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f); + biggerRect.set(progressRect); + int margin = 8; + biggerRect.set(progressRect.left - margin, progressRect.top - margin, progressRect.right + margin, progressRect.bottom + margin); + canvas.saveLayer(biggerRect, null, Canvas.ALL_SAVE_FLAG); + if (progress != -1) { + canvas.drawOval(progressRect, circlePaint); + canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint); + } + canvas.restore(); + + int circleRadius = Utils.dpToPx(getContext(), 14); + int circleMargin = Utils.dpToPx(getContext(), 14); + + int circleY = getHeight() - circleMargin - circleRadius / 2; + int circleX = getWidth() - circleMargin - circleRadius / 2; + + canvas.drawCircle(circleX, circleY, circleRadius, markBgPaint); + + captionDrawable.setBounds(getWidth() - circleMargin - circleRadius, + getHeight() - circleMargin - circleRadius, + getWidth() - circleMargin, + getHeight() - circleMargin); + captionDrawable.setTint(Color.WHITE); + captionDrawable.draw(canvas); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt new file mode 100644 index 0000000..f7ba7ee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -0,0 +1,75 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.view + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import com.google.android.material.button.MaterialButton +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Status +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp + +class TootButton +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MaterialButton(context, attrs, defStyleAttr) { + + private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button) + + init { + if(smallStyle) { + setIconResource(R.drawable.ic_send_24dp) + } else { + setText(R.string.action_send) + iconGravity = ICON_GRAVITY_TEXT_START + } + val padding = resources.getDimensionPixelSize(R.dimen.toot_button_horizontal_padding) + setPadding(padding, 0, padding, 0) + } + + fun setStatusVisibility(visibility: Status.Visibility) { + if(!smallStyle) { + + icon = when (visibility) { + Status.Visibility.PUBLIC -> { + setText(R.string.action_send_public) + null + } + Status.Visibility.UNLISTED -> { + setText(R.string.action_send) + null + } + Status.Visibility.PRIVATE, + Status.Visibility.DIRECT -> { + setText(R.string.action_send) + IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply { sizeDp = 18; colorInt = Color.WHITE } + } + else -> { + null + } + } + } + + } + +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt new file mode 100644 index 0000000..6d6aee4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -0,0 +1,110 @@ +package com.keylesspalace.tusky.components.conversation + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.AsyncPagedListDiffer +import androidx.paging.PagedList +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.NetworkStateViewHolder +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.StatusDisplayOptions + +class ConversationAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val listener: StatusActionListener, + private val topLoadedCallback: () -> Unit, + private val retryCallback: () -> Unit +) : RecyclerView.Adapter() { + + private var networkState: NetworkState? = null + + private val differ: AsyncPagedListDiffer = AsyncPagedListDiffer(object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position, count) + if (position == 0) { + topLoadedCallback() + } + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + notifyItemRangeChanged(position, count, payload) + } + }, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build()) + + fun submitList(list: PagedList) { + differ.submitList(list) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + return when (viewType) { + R.layout.item_network_state -> NetworkStateViewHolder(view, retryCallback) + R.layout.item_conversation -> ConversationViewHolder(view, statusDisplayOptions, + listener) + else -> throw IllegalArgumentException("unknown view type $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (getItemViewType(position)) { + R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0) + R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position)) + } + } + + private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED + + override fun getItemViewType(position: Int): Int { + return if (hasExtraRow() && position == itemCount - 1) { + R.layout.item_network_state + } else { + R.layout.item_conversation + } + } + + override fun getItemCount(): Int { + return differ.itemCount + if (hasExtraRow()) 1 else 0 + } + + fun setNetworkState(newNetworkState: NetworkState?) { + val previousState = this.networkState + val hadExtraRow = hasExtraRow() + this.networkState = newNetworkState + val hasExtraRow = hasExtraRow() + if (hadExtraRow != hasExtraRow) { + if (hadExtraRow) { + notifyItemRemoved(differ.itemCount) + } else { + notifyItemInserted(differ.itemCount) + } + } else if (hasExtraRow && previousState != newNetworkState) { + notifyItemChanged(itemCount - 1) + } + } + + companion object { + + val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = + oldItem == newItem + + override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = + oldItem.id == newItem.id + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt new file mode 100644 index 0000000..7c4ed10 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -0,0 +1,194 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation + +import android.text.Spanned +import android.text.SpannedString +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.util.shouldTrimStatus +import java.util.* + +@Entity(primaryKeys = ["id","accountId"]) +@TypeConverters(Converters::class) +data class ConversationEntity( + val accountId: Long, + val id: String, + val accounts: List, + val unread: Boolean, + @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity +) + +data class ConversationAccountEntity( + val id: String, + val username: String, + val displayName: String, + val avatar: String, + val emojis: List +) { + fun toAccount(): Account { + return Account( + id = id, + username = username, + displayName = displayName, + avatar = avatar, + emojis = emojis, + url = "", + localUsername = "", + note = SpannedString(""), + header = "" + ) + } +} + +@TypeConverters(Converters::class) +data class ConversationStatusEntity( + val id: String, + val url: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val account: ConversationAccountEntity, + val content: Spanned, + val createdAt: Date, + val emojis: List, + val favouritesCount: Int, + val favourited: Boolean, + val bookmarked: Boolean, + val sensitive: Boolean, + val spoilerText: String, + val attachments: ArrayList, + val mentions: Array, + val showingHiddenContent: Boolean, + val expanded: Boolean, + val collapsible: Boolean, + val collapsed: Boolean, + val poll: Poll? + +) { + /** its necessary to override this because Spanned.equals does not work as expected */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConversationStatusEntity + + if (id != other.id) return false + if (url != other.url) return false + if (inReplyToId != other.inReplyToId) return false + if (inReplyToAccountId != other.inReplyToAccountId) return false + if (account != other.account) return false + if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings + if (createdAt != other.createdAt) return false + if (emojis != other.emojis) return false + if (favouritesCount != other.favouritesCount) return false + if (favourited != other.favourited) return false + if (sensitive != other.sensitive) return false + if (spoilerText != other.spoilerText) return false + if (attachments != other.attachments) return false + if (!mentions.contentEquals(other.mentions)) return false + if (showingHiddenContent != other.showingHiddenContent) return false + if (expanded != other.expanded) return false + if (collapsible != other.collapsible) return false + if (collapsed != other.collapsed) return false + if (poll != other.poll) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + (inReplyToId?.hashCode() ?: 0) + result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) + result = 31 * result + account.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + emojis.hashCode() + result = 31 * result + favouritesCount + result = 31 * result + favourited.hashCode() + result = 31 * result + sensitive.hashCode() + result = 31 * result + spoilerText.hashCode() + result = 31 * result + attachments.hashCode() + result = 31 * result + mentions.contentHashCode() + result = 31 * result + showingHiddenContent.hashCode() + result = 31 * result + expanded.hashCode() + result = 31 * result + collapsible.hashCode() + result = 31 * result + collapsed.hashCode() + result = 31 * result + poll.hashCode() + return result + } + + fun toStatus(): Status { + return Status( + id = id, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + content = content, + reblog = null, + createdAt = createdAt, + emojis = emojis, + reblogsCount = 0, + favouritesCount = favouritesCount, + reblogged = false, + favourited = favourited, + bookmarked = bookmarked, + sensitive= sensitive, + spoilerText = spoilerText, + visibility = Status.Visibility.DIRECT, + attachments = attachments, + mentions = mentions, + application = null, + pinned = false, + poll = poll, + card = null) + } +} + +fun Account.toEntity() = + ConversationAccountEntity( + id, + username, + displayName.orEmpty(), + avatar, + emojis ?: emptyList() + ) + +fun Status.toEntity() = + ConversationStatusEntity( + id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, + createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, + spoilerText, attachments, mentions, + false, + false, + shouldTrimStatus(content), + true, + poll + ) + + +fun Conversation.toEntity(accountId: Long) = + ConversationEntity( + accountId, + id, + accounts.map { it.toEntity() }, + unread, + lastStatus!!.toEntity() + ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java new file mode 100644 index 0000000..19ef749 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -0,0 +1,168 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation; + +import android.content.Context; +import android.text.InputFilter; +import android.text.TextUtils; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; +import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.ImageLoadingHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.viewdata.PollViewDataKt; + +import java.util.List; + +public class ConversationViewHolder extends StatusBaseViewHolder { + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + + private TextView conversationNameTextView; + private Button contentCollapseButton; + private ImageView[] avatars; + + private StatusDisplayOptions statusDisplayOptions; + private StatusActionListener listener; + + ConversationViewHolder(View itemView, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener listener) { + super(itemView); + conversationNameTextView = itemView.findViewById(R.id.conversation_name); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); + avatars = new ImageView[]{ + avatar, + itemView.findViewById(R.id.status_avatar_1), + itemView.findViewById(R.id.status_avatar_2) + }; + this.statusDisplayOptions = statusDisplayOptions; + + this.listener = listener; + + } + + @Override + protected int getMediaPreviewHeight(Context context) { + return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); + } + + void setupWithConversation(ConversationEntity conversation) { + ConversationStatusEntity status = conversation.getLastStatus(); + ConversationAccountEntity account = status.getAccount(); + + setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); + + setDisplayName(account.getDisplayName(), account.getEmojis()); + setUsername(account.getUsername()); + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setIsReply(status.getInReplyToId() != null); + setFavourited(status.getFavourited()); + setBookmarked(status.getBookmarked()); + List attachments = status.getAttachments(); + boolean sensitive = status.getSensitive(); + if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), + statusDisplayOptions.useBlurhash()); + + if (attachments.size() == 0) { + hideSensitiveMediaWarning(); + } + // Hide the unused label. + for (TextView mediaLabel : mediaLabels) { + mediaLabel.setVisibility(View.GONE); + } + } else { + setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent()); + // Hide all unused views. + mediaPreviews[0].setVisibility(View.GONE); + mediaPreviews[1].setVisibility(View.GONE); + mediaPreviews[2].setVisibility(View.GONE); + mediaPreviews[3].setVisibility(View.GONE); + hideSensitiveMediaWarning(); + } + + setupButtons(listener, account.getId(), status.getContent().toString(), + statusDisplayOptions); + + setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), + status.getMentions(), status.getEmojis(), + PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); + + setConversationName(conversation.getAccounts()); + + setAvatars(conversation.getAccounts()); + } + + private void setConversationName(List accounts) { + Context context = conversationNameTextView.getContext(); + String conversationName = ""; + if (accounts.size() == 1) { + conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername()); + } else if (accounts.size() == 2) { + conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername()); + } else if (accounts.size() > 2) { + conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2); + } + + conversationNameTextView.setText(conversationName); + } + + private void setAvatars(List accounts) { + for (int i = 0; i < avatars.length; i++) { + ImageView avatarView = avatars[i]; + if (i < accounts.size()) { + ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, + avatarRadius48dp, statusDisplayOptions.animateAvatars()); + avatarView.setVisibility(View.VISIBLE); + } else { + avatarView.setVisibility(View.GONE); + } + } + } + + private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, final StatusActionListener listener) { + /* input filter for TextViews have to be set before text */ + if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { + contentCollapseButton.setOnClickListener(view -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) + listener.onContentCollapsedChange(!collapsed, position); + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (collapsed) { + contentCollapseButton.setText(R.string.status_content_warning_show_more); + content.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setText(R.string.status_content_warning_show_less); + content.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + content.setFilters(NO_INPUT_FILTER); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt new file mode 100644 index 0000000..5d35901 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2017 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 com.keylesspalace.tusky.components.conversation + +import androidx.annotation.MainThread +import androidx.paging.PagedList +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.PagingRequestHelper +import com.keylesspalace.tusky.util.createStatusLiveData +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.concurrent.Executor + +/** + * This boundary callback gets notified when user reaches to the edges of the list such that the + * database cannot provide any more data. + *

+ * The boundary callback might be called multiple times for the same direction so it does its own + * rate limiting using the PagingRequestHelper class. + */ +class ConversationsBoundaryCallback( + private val accountId: Long, + private val mastodonApi: MastodonApi, + private val handleResponse: (Long, List?) -> Unit, + private val ioExecutor: Executor, + private val networkPageSize: Int) + : PagedList.BoundaryCallback() { + + val helper = PagingRequestHelper(ioExecutor) + val networkState = helper.createStatusLiveData() + + /** + * Database returned 0 items. We should query the backend for more items. + */ + @MainThread + override fun onZeroItemsLoaded() { + helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) { + mastodonApi.getConversations(null, networkPageSize) + .enqueue(createWebserviceCallback(it)) + } + } + + /** + * User reached to the end of the list. + */ + @MainThread + override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) { + helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) { + mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize) + .enqueue(createWebserviceCallback(it)) + } + } + + /** + * every time it gets new items, boundary callback simply inserts them into the database and + * paging library takes care of refreshing the list if necessary. + */ + private fun insertItemsIntoDb( + response: Response>, + it: PagingRequestHelper.Request.Callback) { + ioExecutor.execute { + handleResponse(accountId, response.body()) + it.recordSuccess() + } + } + + override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) { + // ignored, since we only ever append to what's in the DB + } + + private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback> { + return object : Callback> { + override fun onFailure(call: Call>, t: Throwable) { + it.recordFailure(t) + } + + override fun onResponse(call: Call>, response: Response>) { + insertItemsIntoDb(response, it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt new file mode 100644 index 0000000..48926a6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -0,0 +1,204 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import androidx.paging.PagedList +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import kotlinx.android.synthetic.main.fragment_timeline.* +import javax.inject.Inject + +class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } + + private lateinit var adapter: ConversationAdapter + + private var layoutManager: LinearLayoutManager? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) + + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + renderStatusAsMention = preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + ) + + adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) + + recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + layoutManager = LinearLayoutManager(view.context) + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + progressBar.hide() + statusView.hide() + + initSwipeToRefresh() + + viewModel.conversations.observe(viewLifecycleOwner, Observer> { + adapter.submitList(it) + }) + viewModel.networkState.observe(viewLifecycleOwner, Observer { + adapter.setNetworkState(it) + }) + + viewModel.load() + + } + + private fun initSwipeToRefresh() { + viewModel.refreshState.observe(viewLifecycleOwner, Observer { + swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING + }) + swipeRefreshLayout.setOnRefreshListener { + viewModel.refresh() + } + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun onTopLoaded() { + recyclerView.scrollToPosition(0) + } + + override fun onReblog(reblog: Boolean, position: Int) { + // its impossible to reblog private messages + } + + override fun onFavourite(favourite: Boolean, position: Int) { + viewModel.favourite(favourite, position) + } + + override fun onBookmark(favourite: Boolean, position: Int) { + viewModel.bookmark(favourite, position) + } + + override fun onMore(view: View, position: Int) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + more(it.toStatus(), view, position) + } + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + viewMedia(attachmentIndex, it.toStatus(), view) + } + } + + override fun onViewThread(position: Int) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + viewThread(it.toStatus()) + } + } + + override fun onViewReplyTo(position: Int) { + // there are no Reply to labels in conversations + } + + override fun onOpenReblog(position: Int) { + // there are no reblogs in search results + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + viewModel.expandHiddenStatus(expanded, position) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + viewModel.showContent(isShowing, position) + } + + override fun onLoadMore(position: Int) { + // not using the old way of pagination + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + viewModel.collapseLongStatus(isCollapsed, position) + } + + override fun onViewAccount(id: String) { + val intent = AccountActivity.getIntent(requireContext(), id) + startActivity(intent) + } + + override fun onViewTag(tag: String) { + val intent = Intent(context, ViewTagActivity::class.java) + intent.putExtra("hashtag", tag) + startActivity(intent) + } + + override fun removeItem(position: Int) { + viewModel.remove(position) + } + + override fun onReply(position: Int) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + reply(it.toStatus()) + } + } + + private fun jumpToTop() { + if (isAdded) { + layoutManager?.scrollToPosition(0) + recyclerView.stopScroll() + } + } + + override fun onReselect() { + jumpToTop() + } + + override fun onVoteInPoll(position: Int, choices: MutableList) { + viewModel.voteInPoll(position, choices) + } + + companion object { + fun newInstance() = ConversationsFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt new file mode 100644 index 0000000..3cb4745 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt @@ -0,0 +1,111 @@ +package com.keylesspalace.tusky.components.conversation + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.paging.Config +import androidx.paging.toLiveData +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Listing +import com.keylesspalace.tusky.util.NetworkState +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.concurrent.Executors +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { + + private val ioExecutor = Executors.newSingleThreadExecutor() + + companion object { + private const val DEFAULT_PAGE_SIZE = 20 + } + + @MainThread + fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData { + val networkState = MutableLiveData() + if(showLoadingIndicator) { + networkState.value = NetworkState.LOADING + } + + mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue( + object : Callback> { + override fun onFailure(call: Call>, t: Throwable) { + // retrofit calls this on main thread so safe to call set value + networkState.value = NetworkState.error(t.message) + } + + override fun onResponse(call: Call>, response: Response>) { + ioExecutor.execute { + db.runInTransaction { + db.conversationDao().deleteForAccount(accountId) + insertResultIntoDb(accountId, response.body()) + } + // since we are in bg thread now, post the result. + networkState.postValue(NetworkState.LOADED) + } + } + } + ) + return networkState + } + + @MainThread + fun conversations(accountId: Long): Listing { + // create a boundary callback which will observe when the user reaches to the edges of + // the list and update the database with extra data. + val boundaryCallback = ConversationsBoundaryCallback( + accountId = accountId, + mastodonApi = mastodonApi, + handleResponse = this::insertResultIntoDb, + ioExecutor = ioExecutor, + networkPageSize = DEFAULT_PAGE_SIZE) + // we are using a mutable live data to trigger refresh requests which eventually calls + // refresh method and gets a new live data. Each refresh request by the user becomes a newly + // dispatched data in refreshTrigger + val refreshTrigger = MutableLiveData() + val refreshState = Transformations.switchMap(refreshTrigger) { + refresh(accountId, true) + } + + // We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder + val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData( + config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false), + boundaryCallback = boundaryCallback + ) + + return Listing( + pagedList = livePagedList, + networkState = boundaryCallback.networkState, + retry = { + boundaryCallback.helper.retryAllFailed() + }, + refresh = { + refreshTrigger.value = null + }, + refreshState = refreshState + ) + } + + fun deleteCacheForAccount(accountId: Long) { + Single.fromCallable { + db.conversationDao().deleteForAccount(accountId) + }.subscribeOn(Schedulers.io()) + .subscribe() + } + + private fun insertResultIntoDb(accountId: Long, result: List?) { + result?.filter { it.lastStatus != null } + ?.map{ it.toEntity(accountId) } + ?.let { db.conversationDao().insert(it) } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt new file mode 100644 index 0000000..c6fa84b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -0,0 +1,142 @@ +package com.keylesspalace.tusky.components.conversation + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.paging.PagedList +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.Listing +import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.RxAwareViewModel +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class ConversationsViewModel @Inject constructor( + private val repository: ConversationsRepository, + private val timelineCases: TimelineCases, + private val database: AppDatabase, + private val accountManager: AccountManager +) : RxAwareViewModel() { + + private val repoResult = MutableLiveData>() + + val conversations: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } + val networkState: LiveData = Transformations.switchMap(repoResult) { it.networkState } + val refreshState: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + + fun load() { + val accountId = accountManager.activeAccount?.id ?: return + if (repoResult.value == null) { + repository.refresh(accountId, false) + } + repoResult.value = repository.conversations(accountId) + } + + fun refresh() { + repoResult.value?.refresh?.invoke() + } + + fun retry() { + repoResult.value?.retry?.invoke() + } + + fun favourite(favourite: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + timelineCases.favourite(conversation.lastStatus.toStatus(), favourite) + .flatMap { + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(favourited = favourite) + ) + + database.conversationDao().insert(newConversation) + } + .subscribeOn(Schedulers.io()) + .doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) } + .onErrorReturnItem(0) + .subscribe() + .autoDispose() + } + + } + + fun bookmark(bookmark: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark) + .flatMap { + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) + ) + + database.conversationDao().insert(newConversation) + } + .subscribeOn(Schedulers.io()) + .doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) } + .onErrorReturnItem(0) + .subscribe() + .autoDispose() + } + + } + + fun voteInPoll(position: Int, choices: MutableList) { + conversations.value?.getOrNull(position)?.let { conversation -> + timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices) + .flatMap { poll -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(poll = poll) + ) + + database.conversationDao().insert(newConversation) + } + .subscribeOn(Schedulers.io()) + .doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) } + .onErrorReturnItem(0) + .subscribe() + .autoDispose() + } + + } + + fun expandHiddenStatus(expanded: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(expanded = expanded) + ) + saveConversationToDb(newConversation) + } + } + + fun collapseLongStatus(collapsed: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(collapsed = collapsed) + ) + saveConversationToDb(newConversation) + } + } + + fun showContent(showing: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) + ) + saveConversationToDb(newConversation) + } + } + + fun remove(position: Int) { + conversations.value?.getOrNull(position)?.let { + refresh() + } + } + + private fun saveConversationToDb(conversation: ConversationEntity) { + database.conversationDao().insert(conversation) + .subscribeOn(Schedulers.io()) + .subscribe() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt new file mode 100644 index 0000000..e1fc70f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -0,0 +1,161 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import android.content.Context +import android.net.Uri +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.IOUtils +import io.reactivex.Completable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +class DraftHelper @Inject constructor( + val context: Context, + db: AppDatabase +) { + + private val draftDao = db.draftDao() + + fun saveDraft( + draftId: Int, + accountId: Long, + inReplyToId: String?, + content: String?, + contentWarning: String?, + sensitive: Boolean, + visibility: Status.Visibility, + mediaUris: List, + mediaDescriptions: List, + poll: NewPoll?, + formattingSyntax: String, + failedToSend: Boolean + ): Completable { + return Single.fromCallable { + + val draftDirectory = context.getExternalFilesDir("Tusky") + + if (draftDirectory == null || !(draftDirectory.exists())) { + Log.e("DraftHelper", "Error obtaining directory to save media.") + throw Exception() + } + + val uris = mediaUris.map { uriString -> + uriString.toUri() + }.map { uri -> + if (uri.isNotInFolder(draftDirectory)) { + uri.copyToFolder(draftDirectory) + } else { + uri + } + } + + val types = uris.map { uri -> + val mimeType = context.contentResolver.getType(uri) + when (mimeType?.substring(0, mimeType.indexOf('/'))) { + "video" -> DraftAttachment.Type.VIDEO + "image" -> DraftAttachment.Type.IMAGE + "audio" -> DraftAttachment.Type.AUDIO + else -> throw IllegalStateException("unknown media type") + } + } + + val attachments: MutableList = mutableListOf() + for (i in mediaUris.indices) { + attachments.add( + DraftAttachment( + uriString = uris[i].toString(), + description = mediaDescriptions[i], + type = types[i] + ) + ) + } + + DraftEntity( + id = draftId, + accountId = accountId, + inReplyToId = inReplyToId, + content = content, + contentWarning = contentWarning, + sensitive = sensitive, + visibility = visibility, + attachments = attachments, + poll = poll, + formattingSyntax = formattingSyntax, + failedToSend = failedToSend + ) + + }.flatMapCompletable { draft -> + draftDao.insertOrReplace(draft) + }.subscribeOn(Schedulers.io()) + } + + fun deleteDraftAndAttachments(draftId: Int): Completable { + return draftDao.find(draftId) + .flatMapCompletable { draft -> + deleteDraftAndAttachments(draft) + } + } + + fun deleteDraftAndAttachments(draft: DraftEntity): Completable { + return deleteAttachments(draft) + .andThen(draftDao.delete(draft.id)) + } + + fun deleteAttachments(draft: DraftEntity): Completable { + return Completable.fromCallable { + draft.attachments.forEach { attachment -> + if (context.contentResolver.delete(attachment.uri, null, null) == 0) { + Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") + } + } + }.subscribeOn(Schedulers.io()) + } + + private fun Uri.isNotInFolder(folder: File): Boolean { + val filePath = path ?: return true + return File(filePath).parentFile == folder + } + + private fun Uri.copyToFolder(folder: File): Uri { + val contentResolver = context.contentResolver + + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + + val mimeType = contentResolver.getType(this) + val map = MimeTypeMap.getSingleton() + val fileExtension = map.getExtensionFromMimeType(mimeType) + + val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension) + val file = File(folder, filename) + IOUtils.copyToFile(contentResolver, this, file) + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt new file mode 100644 index 0000000..69403fd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -0,0 +1,81 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import android.view.ViewGroup +import android.widget.ImageView +import androidx.appcompat.widget.AppCompatImageView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.db.DraftAttachment + +class DraftMediaAdapter( + private val attachmentClick: () -> Unit +) : ListAdapter( + object: DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + return oldItem == newItem + } + + } +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { + return DraftMediaViewHolder(AppCompatImageView(parent.context)) + } + + override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) { + getItem(position)?.let { attachment -> + if (attachment.type == DraftAttachment.Type.AUDIO) { + holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) + } else { + Glide.with(holder.itemView.context) + .load(attachment.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(holder.imageView) + } + } + } + + inner class DraftMediaViewHolder(val imageView: ImageView) + : RecyclerView.ViewHolder(imageView) { + init { + val thumbnailViewSize = + imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) + val margin = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + layoutParams.setMargins(margin, 0, margin, marginBottom) + imageView.layoutParams = layoutParams + imageView.scaleType = ImageView.ScaleType.CENTER_CROP + imageView.setOnClickListener { + attachmentClick() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt new file mode 100644 index 0000000..b1255e8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -0,0 +1,199 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.widget.LinearLayout +import android.widget.Toast +import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.SavedTootActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.databinding.ActivityDraftsBinding +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.uber.autodispose.android.lifecycle.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import retrofit2.HttpException +import javax.inject.Inject + +class DraftsActivity : BaseActivity(), DraftActionListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: DraftsViewModel by viewModels { viewModelFactory } + + private lateinit var binding: ActivityDraftsBinding + private lateinit var bottomSheet: BottomSheetBehavior + + private var oldDraftsButton: MenuItem? = null + + override fun onCreate(savedInstanceState: Bundle?) { + + super.onCreate(savedInstanceState) + + binding = ActivityDraftsBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.apply { + title = getString(R.string.title_drafts) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status) + + val adapter = DraftsAdapter(this) + + binding.draftsRecyclerView.adapter = adapter + binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this) + binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + + bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) + + viewModel.drafts.observe(this) { draftList -> + if (draftList.isEmpty()) { + binding.draftsRecyclerView.hide() + binding.draftsErrorMessageView.show() + } else { + binding.draftsRecyclerView.show() + binding.draftsErrorMessageView.hide() + adapter.submitList(draftList) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.drafts, menu) + oldDraftsButton = menu.findItem(R.id.action_old_drafts) + viewModel.showOldDraftsButton() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { showOldDraftsButton -> + oldDraftsButton?.isVisible = showOldDraftsButton + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + R.id.action_old_drafts -> { + val intent = Intent(this, SavedTootActivity::class.java) + startActivityWithSlideInAnimation(intent) + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onOpenDraft(draft: DraftEntity) { + + if (draft.inReplyToId != null) { + bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED + viewModel.getToot(draft.inReplyToId) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this) + .subscribe({ status -> + val composeOptions = ComposeActivity.ComposeOptions( + draftId = draft.id, + tootText = draft.content, + contentWarning = draft.contentWarning, + inReplyToId = draft.inReplyToId, + replyingStatusContent = status.content.toString(), + replyingStatusAuthor = status.account.localUsername, + draftAttachments = draft.attachments, + poll = draft.poll, + sensitive = draft.sensitive, + visibility = draft.visibility, + formattingSyntax = draft.formattingSyntax + ) + + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + + startActivity(ComposeActivity.startIntent(this, composeOptions)) + + }, { throwable -> + + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + + Log.w(TAG, "failed loading reply information", throwable) + + if (throwable is HttpException && throwable.code() == 404) { + // the original status to which a reply was drafted has been deleted + // let's open the ComposeActivity without reply information + Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show() + openDraftWithoutReply(draft) + } else { + Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) + .show() + } + }) + } else { + openDraftWithoutReply(draft) + } + } + + private fun openDraftWithoutReply(draft: DraftEntity) { + val composeOptions = ComposeActivity.ComposeOptions( + draftId = draft.id, + tootText = draft.content, + contentWarning = draft.contentWarning, + draftAttachments = draft.attachments, + poll = draft.poll, + sensitive = draft.sensitive, + visibility = draft.visibility, + formattingSyntax = draft.formattingSyntax + ) + + startActivity(ComposeActivity.startIntent(this, composeOptions)) + } + + override fun onDeleteDraft(draft: DraftEntity) { + viewModel.deleteDraft(draft) + Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + viewModel.restoreDraft(draft) + } + .show() + } + + companion object { + const val TAG = "DraftsActivity" + + fun newIntent(context: Context) = Intent(context, DraftsActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt new file mode 100644 index 0000000..5dfbcea --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -0,0 +1,92 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.databinding.ItemDraftBinding +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.util.BindingViewHolder +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.visible + +interface DraftActionListener { + fun onOpenDraft(draft: DraftEntity) + fun onDeleteDraft(draft: DraftEntity) +} + +class DraftsAdapter( + private val listener: DraftActionListener +) : PagedListAdapter>( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { + return oldItem == newItem + } + } +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder { + + val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) + + val viewHolder = BindingViewHolder(binding) + + binding.draftMediaPreview.layoutManager = LinearLayoutManager(binding.root.context, RecyclerView.HORIZONTAL, false) + binding.draftMediaPreview.adapter = DraftMediaAdapter { + getItem(viewHolder.adapterPosition)?.let { draft -> + listener.onOpenDraft(draft) + } + } + + return viewHolder + } + + override fun onBindViewHolder(holder: BindingViewHolder, position: Int) { + getItem(position)?.let { draft -> + holder.binding.root.setOnClickListener { + listener.onOpenDraft(draft) + } + holder.binding.deleteButton.setOnClickListener { + listener.onDeleteDraft(draft) + } + holder.binding.draftSendingInfo.visible(draft.failedToSend) + + holder.binding.contentWarning.visible(!draft.contentWarning.isNullOrEmpty()) + holder.binding.contentWarning.text = draft.contentWarning + holder.binding.content.text = draft.content + + holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty()) + (holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments) + + if (draft.poll != null) { + holder.binding.draftPoll.show() + holder.binding.draftPoll.setPoll(draft.poll) + } else { + holder.binding.draftPoll.hide() + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt new file mode 100644 index 0000000..9eca963 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -0,0 +1,69 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import androidx.lifecycle.ViewModel +import androidx.paging.toLiveData +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import io.reactivex.Observable +import io.reactivex.Single +import javax.inject.Inject + +class DraftsViewModel @Inject constructor( + val database: AppDatabase, + val accountManager: AccountManager, + val api: MastodonApi, + val draftHelper: DraftHelper +) : ViewModel() { + + val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20) + + private val deletedDrafts: MutableList = mutableListOf() + + fun showOldDraftsButton(): Observable { + return database.tootDao().savedTootCount() + .map { count -> count > 0 } + } + + fun deleteDraft(draft: DraftEntity) { + // this does not immediately delete media files to avoid unnecessary file operations + // in case the user decides to restore the draft + database.draftDao().delete(draft.id) + .subscribe() + deletedDrafts.add(draft) + } + + fun restoreDraft(draft: DraftEntity) { + database.draftDao().insertOrReplace(draft) + .subscribe() + deletedDrafts.remove(draft) + } + + fun getToot(tootId: String): Single { + return api.statusSingle(tootId) + } + + override fun onCleared() { + deletedDrafts.forEach { + draftHelper.deleteAttachments(it).subscribe() + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt new file mode 100644 index 0000000..f4505ad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt @@ -0,0 +1,47 @@ +package com.keylesspalace.tusky.components.instancemute + +import android.os.Bundle +import android.view.MenuItem +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import javax.inject.Inject +import kotlinx.android.synthetic.main.toolbar_basic.* + +class InstanceListActivity: BaseActivity(), HasAndroidInjector { + + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_account_list) + + setSupportActionBar(toolbar) + supportActionBar?.apply { + setTitle(R.string.title_domain_mutes) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + supportFragmentManager + .beginTransaction() + .replace(R.id.fragment_container, InstanceListFragment()) + .commit() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun androidInjector() = androidInjector + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt new file mode 100644 index 0000000..62ab7ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt @@ -0,0 +1,57 @@ +package com.keylesspalace.tusky.components.instancemute.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener +import kotlinx.android.synthetic.main.item_muted_domain.view.* + +class DomainMutesAdapter(private val actionListener: InstanceActionListener): RecyclerView.Adapter() { + var instances: MutableList = mutableListOf() + var bottomLoading: Boolean = false + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_muted_domain, parent, false), actionListener) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.setupWithInstance(instances[position]) + } + + override fun getItemCount(): Int { + var count = instances.size + if (bottomLoading) + ++count + return count + } + + fun addItems(newInstances: List) { + val end = instances.size + instances.addAll(newInstances) + notifyItemRangeInserted(end, instances.size) + } + + fun addItem(instance: String) { + instances.add(instance) + notifyItemInserted(instances.size) + } + + fun removeItem(position: Int) + { + if (position >= 0 && position < instances.size) { + instances.removeAt(position) + notifyItemRemoved(position) + } + } + + + class ViewHolder(rootView: View, private val actionListener: InstanceActionListener): RecyclerView.ViewHolder(rootView) { + fun setupWithInstance(instance: String) { + itemView.muted_domain.text = instance + itemView.muted_domain_unmute.setOnClickListener { + actionListener.mute(false, instance, adapterPosition) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt new file mode 100644 index 0000000..bb850bd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt @@ -0,0 +1,179 @@ +package com.keylesspalace.tusky.components.instancemute.fragment + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter +import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.fragment.BaseFragment +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.fragment_instance_list.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject + +class InstanceListFragment: BaseFragment(), Injectable, InstanceActionListener { + @Inject + lateinit var api: MastodonApi + + private var fetching = false + private var bottomId: String? = null + private var adapter = DomainMutesAdapter(this) + private lateinit var scrollListener: EndlessOnScrollListener + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return inflater.inflate(R.layout.fragment_instance_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + recyclerView.setHasFixedSize(true) + recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + recyclerView.adapter = adapter + + val layoutManager = LinearLayoutManager(view.context) + recyclerView.layoutManager = layoutManager + + scrollListener = object : EndlessOnScrollListener(layoutManager) { + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + if (bottomId != null) { + fetchInstances(bottomId) + } + } + } + + recyclerView.addOnScrollListener(scrollListener) + fetchInstances() + } + + override fun mute(mute: Boolean, instance: String, position: Int) { + if (mute) { + api.blockDomain(instance).enqueue(object: Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e(TAG, "Error muting domain $instance") + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + adapter.addItem(instance) + } else { + Log.e(TAG, "Error muting domain $instance") + } + } + }) + } else { + api.unblockDomain(instance).enqueue(object: Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e(TAG, "Error unmuting domain $instance") + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + adapter.removeItem(position) + Snackbar.make(recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + mute(true, instance, position) + } + .show() + } else { + Log.e(TAG, "Error unmuting domain $instance") + } + } + }) + } + } + + private fun fetchInstances(id: String? = null) { + if (fetching) { + return + } + fetching = true + instanceProgressBar.show() + + if (id != null) { + recyclerView.post { adapter.bottomLoading = true } + } + + api.domainBlocks(id, bottomId) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ response -> + val instances = response.body() + + if (response.isSuccessful && instances != null) { + onFetchInstancesSuccess(instances, response.headers().get("Link")) + } else { + onFetchInstancesFailure(Exception(response.message())) + } + }, {throwable -> + onFetchInstancesFailure(throwable) + }) + } + + private fun onFetchInstancesSuccess(instances: List, linkHeader: String?) { + adapter.bottomLoading = false + instanceProgressBar.hide() + + val links = HttpHeaderLink.parse(linkHeader) + val next = HttpHeaderLink.findByRelationType(links, "next") + val fromId = next?.uri?.getQueryParameter("max_id") + adapter.addItems(instances) + bottomId = fromId + fetching = false + + if (adapter.itemCount == 0) { + messageView.show() + messageView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) + } else { + messageView.hide() + } + } + + private fun onFetchInstancesFailure(throwable: Throwable) { + fetching = false + instanceProgressBar.hide() + Log.e(TAG, "Fetch failure", throwable) + + if (adapter.itemCount == 0) { + messageView.show() + if (throwable is IOException) { + messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + messageView.hide() + this.fetchInstances(null) + } + } else { + messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + messageView.hide() + this.fetchInstances(null) + } + } + } + } + + companion object { + private const val TAG = "InstanceList" // logging tag + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt new file mode 100644 index 0000000..97d59cc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.components.instancemute.interfaces + +interface InstanceActionListener { + fun mute(mute: Boolean, instance: String, position: Int) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt new file mode 100644 index 0000000..0507d5c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -0,0 +1,83 @@ +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Marker +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isLessThan +import javax.inject.Inject + +class NotificationFetcher @Inject constructor( + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val notifier: Notifier +) { + fun fetchAndShow() { + for (account in accountManager.getAllAccountsOrderedByActive()) { + if (account.notificationsEnabled) { + try { + val notifications = fetchNotifications(account) + notifications.forEachIndexed { index, notification -> + notifier.show(notification, account, index == 0) + } + accountManager.saveAccount(account) + } catch (e: Exception) { + Log.w(TAG, "Error while fetching notifications", e) + } + } + } + } + + private fun fetchNotifications(account: AccountEntity): MutableList { + val authHeader = String.format("Bearer %s", account.accessToken) + // We fetch marker to not load/show notifications which user has already seen + val marker = fetchMarker(authHeader, account) + if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) { + account.lastNotificationId = marker.lastReadId + } + Log.d(TAG, "getting Notifications for " + account.fullName) + val notifications = mastodonApi.notificationsWithAuth( + authHeader, + account.domain, + account.lastNotificationId, + Notification.Type.asStringList + ).blockingGet() + + val newId = account.lastNotificationId + var newestId = "" + val result = mutableListOf() + for (notification in notifications.reversed()) { + val currentId = notification.id + if (newestId.isLessThan(currentId)) { + newestId = currentId + account.lastNotificationId = currentId + } + if (newId.isLessThan(currentId)) { + result.add(notification) + } + } + return result + } + + private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { + return try { + val allMarkers = mastodonApi.markersWithAuth( + authHeader, + account.domain, + listOf("notifications") + ).blockingGet() + val notificationMarker = allMarkers["notifications"] + Log.d(TAG, "Fetched marker: $notificationMarker") + notificationMarker + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch marker", e) + null + } + } + + companion object { + const val TAG = "NotificationFetcher" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java new file mode 100644 index 0000000..8cc5900 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -0,0 +1,763 @@ +/* Copyright 2018 Jeremiasz Nelz + * Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications; + +import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.os.Build; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.RemoteInput; +import androidx.core.app.TaskStackBuilder; +import androidx.core.content.ContextCompat; +import androidx.work.Constraints; +import androidx.work.NetworkType; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.resource.bitmap.RoundedCorners; +import com.bumptech.glide.request.FutureTarget; +import com.keylesspalace.tusky.BuildConfig; +import com.keylesspalace.tusky.MainActivity; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.entity.ChatMessage; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Poll; +import com.keylesspalace.tusky.entity.PollOption; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver; +import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; +import com.keylesspalace.tusky.util.StringUtils; +import com.keylesspalace.tusky.viewdata.PollViewDataKt; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + +public class NotificationHelper { + + private static int notificationId = 0; + + /** + * constants used in Intents + */ + public static final String ACCOUNT_ID = "account_id"; + + private static final String TAG = "NotificationHelper"; + + public static final String REPLY_ACTION = "REPLY_ACTION"; + + public static final String COMPOSE_ACTION = "COMPOSE_ACTION"; + + public static final String CHAT_REPLY_ACTION = "CHAT_REPLY_ACTION"; + + public static final String KEY_REPLY = "KEY_REPLY"; + + public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID"; + + public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER"; + + public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME"; + + public static final String KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID"; + + public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID"; + + public static final String KEY_VISIBILITY = "KEY_VISIBILITY"; + + public static final String KEY_SPOILER = "KEY_SPOILER"; + + public static final String KEY_MENTIONS = "KEY_MENTIONS"; + + public static final String KEY_CITED_TEXT = "KEY_CITED_TEXT"; + + public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL"; + + public static final String KEY_CHAT_ID = "KEY_CHAT_ID"; + + /** + * notification channels used on Android O+ + **/ + public static final String CHANNEL_MENTION = "CHANNEL_MENTION"; + public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW"; + public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST"; + public static final String CHANNEL_BOOST = "CHANNEL_BOOST"; + public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; + public static final String CHANNEL_POLL = "CHANNEL_POLL"; + public static final String CHANNEL_EMOJI_REACTION = "CHANNEL_EMOJI_REACTION"; + public static final String CHANNEL_CHAT_MESSAGES = "CHANNEL_CHAT_MESSAGES"; + public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; + public static final String CHANNEL_MOVE = "CHANNEL_MOVE"; + + /** + * WorkManager Tag + */ + private static final String NOTIFICATION_PULL_TAG = "pullNotifications"; + + /** + * by setting this as false, it's possible to test legacy notification channels on newer devices + */ + // public static final boolean NOTIFICATION_USE_CHANNELS = false; + public static final boolean NOTIFICATION_USE_CHANNELS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + + /** + * Takes a given Mastodon notification and either creates a new Android notification or updates + * the state of the existing notification to reflect the new interaction. + * + * @param context to access application preferences and services + * @param body a new Mastodon notification + * @param account the account for which the notification should be shown + */ + + public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) { + body = Notification.rewriteToStatusTypeIfNeeded(body, account.getAccountId()); + + if (!filterNotification(account, body, context)) { + return; + } + + // Pleroma extension: don't notify about seen notifications + if (body.getPleroma() != null && body.getPleroma().getSeen()) { + return; + } + + if (body.getStatus() != null && + (body.getStatus().isUserMuted() || + body.getStatus().isThreadMuted())) { + return; + } + + String rawCurrentNotifications = account.getActiveNotifications(); + JSONArray currentNotifications; + + try { + currentNotifications = new JSONArray(rawCurrentNotifications); + } catch (JSONException e) { + currentNotifications = new JSONArray(); + } + + for (int i = 0; i < currentNotifications.length(); i++) { + try { + if (currentNotifications.getString(i).equals(body.getAccount().getName())) { + currentNotifications.remove(i); + break; + } + } catch (JSONException e) { + Log.d(TAG, Log.getStackTraceString(e)); + } + } + + currentNotifications.put(body.getAccount().getName()); + + account.setActiveNotifications(currentNotifications.toString()); + + // Notification group member + // ========================= + final NotificationCompat.Builder builder = newNotification(context, body, account, false); + + notificationId++; + + builder.setContentTitle(titleForType(context, body, account)) + .setContentText(bodyForType(body, context)); + + if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) { + builder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(bodyForType(body, context))); + } + + //load the avatar synchronously + Bitmap accountAvatar; + try { + FutureTarget target = Glide.with(context) + .asBitmap() + .load(body.getAccount().getAvatar()) + .transform(new RoundedCorners(20)) + .submit(); + + accountAvatar = target.get(); + } catch (ExecutionException | InterruptedException e) { + Log.d(TAG, "error loading account avatar", e); + accountAvatar = BitmapFactory.decodeResource(context.getResources(), R.drawable.avatar_default); + } + + builder.setLargeIcon(accountAvatar); + + // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat + if(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if(body.getType() == Notification.Type.MENTION) { + RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY) + .setLabel(context.getString(R.string.label_quick_reply)) + .build(); + + PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account); + + NotificationCompat.Action quickReplyAction = + new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, + context.getString(R.string.action_quick_reply), quickReplyPendingIntent) + .addRemoteInput(replyRemoteInput) + .build(); + + builder.addAction(quickReplyAction); + + PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account); + + NotificationCompat.Action composeAction = + new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, + context.getString(R.string.action_compose_shortcut), composePendingIntent) + .build(); + + builder.addAction(composeAction); + } else if(body.getType() == Notification.Type.CHAT_MESSAGE) { + RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY) + .setLabel(context.getString(R.string.label_quick_reply)) + .build(); + + PendingIntent quickReplyPendingIntent = getStatusReplyIntent(CHAT_REPLY_ACTION, context, body, account); + + NotificationCompat.Action quickReplyAction = + new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, + context.getString(R.string.action_quick_reply), quickReplyPendingIntent) + .addRemoteInput(replyRemoteInput) + .build(); + + builder.addAction(quickReplyAction); + } + } + + builder.setSubText(account.getFullName()); + builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); + builder.setCategory(NotificationCompat.CATEGORY_SOCIAL); + builder.setOnlyAlertOnce(true); + + // only alert for the first notification of a batch to avoid multiple alerts at once + if(!isFirstOfBatch) { + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); + } + + // Summary + // ======= + final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true); + + if (currentNotifications.length() != 1) { + try { + String title = context.getString(R.string.notification_title_summary, currentNotifications.length()); + String text = joinNames(context, currentNotifications); + summaryBuilder.setContentTitle(title) + .setContentText(text); + } catch (JSONException e) { + Log.d(TAG, Log.getStackTraceString(e)); + } + } + + summaryBuilder.setSubText(account.getFullName()); + summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); + summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL); + summaryBuilder.setOnlyAlertOnce(true); + summaryBuilder.setGroupSummary(true); + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + + notificationManager.notify(notificationId, builder.build()); + if (currentNotifications.length() == 1) { + notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build()); + } else { + notificationManager.notify((int) account.getId(), summaryBuilder.build()); + } + } + + private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) { + Intent summaryResultIntent = new Intent(context, MainActivity.class); + summaryResultIntent.putExtra(ACCOUNT_ID, account.getId()); + TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); + summaryStackBuilder.addParentStack(MainActivity.class); + summaryStackBuilder.addNextIntent(summaryResultIntent); + + PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), + PendingIntent.FLAG_UPDATE_CURRENT); + + // we have to switch account here + Intent eventResultIntent = new Intent(context, MainActivity.class); + eventResultIntent.putExtra(ACCOUNT_ID, account.getId()); + TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context); + eventStackBuilder.addParentStack(MainActivity.class); + eventStackBuilder.addNextIntent(eventResultIntent); + + PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(), + PendingIntent.FLAG_UPDATE_CURRENT); + + Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class); + deleteIntent.putExtra(ACCOUNT_ID, account.getId()); + PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body)) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent) + .setDeleteIntent(deletePendingIntent) + .setColor(BuildConfig.FLAVOR == "green" ? Color.parseColor("#19A341") : ContextCompat.getColor(context, R.color.tusky_blue)) + .setGroup(account.getAccountId()) + .setAutoCancel(true) + .setShortcutId(Long.toString(account.getId())) + .setDefaults(0); // So it doesn't ring twice, notify only in Target callback + + setupPreferences(account, builder); + + return builder; + } + + private static PendingIntent getStatusReplyIntent(String action, Context context, Notification body, AccountEntity account) { + Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class) + .setAction(action) + .putExtra(KEY_SENDER_ACCOUNT_ID, account.getId()) + .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier()) + .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName()) + .putExtra(KEY_NOTIFICATION_ID, notificationId); + + if(action == CHAT_REPLY_ACTION) { + replyIntent.putExtra(KEY_CHAT_ID, body.getChatMessage().getChatId()); + } else { + Status status = body.getStatus(); + + String citedLocalAuthor = status.getAccount().getLocalUsername(); + String citedText = status.getContent().toString(); + String inReplyToId = status.getId(); + Status actionableStatus = status.getActionableStatus(); + Status.Visibility replyVisibility = actionableStatus.getVisibility(); + String contentWarning = actionableStatus.getSpoilerText(); + Status.Mention[] mentions = actionableStatus.getMentions(); + List mentionedUsernames = new ArrayList<>(); + mentionedUsernames.add(actionableStatus.getAccount().getUsername()); + for (Status.Mention mention : mentions) { + mentionedUsernames.add(mention.getUsername()); + } + mentionedUsernames.removeAll(Collections.singleton(account.getUsername())); + mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames)); + + replyIntent.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor) + .putExtra(KEY_CITED_TEXT, citedText) + .putExtra(KEY_CITED_STATUS_ID, inReplyToId) + .putExtra(KEY_VISIBILITY, replyVisibility) + .putExtra(KEY_SPOILER, contentWarning) + .putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0])); + } + + return PendingIntent.getBroadcast(context.getApplicationContext(), + notificationId, + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { + if (NOTIFICATION_USE_CHANNELS) { + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + String[] channelIds = new String[]{ + CHANNEL_MENTION + account.getIdentifier(), + CHANNEL_FOLLOW + account.getIdentifier(), + CHANNEL_FOLLOW_REQUEST + account.getIdentifier(), + CHANNEL_BOOST + account.getIdentifier(), + CHANNEL_FAVOURITE + account.getIdentifier(), + CHANNEL_POLL + account.getIdentifier(), + CHANNEL_EMOJI_REACTION + account.getIdentifier(), + CHANNEL_CHAT_MESSAGES + account.getIdentifier(), + CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), + CHANNEL_MOVE + account.getIdentifier() + }; + int[] channelNames = { + R.string.notification_mention_name, + R.string.notification_follow_name, + R.string.notification_follow_request_name, + R.string.notification_boost_name, + R.string.notification_favourite_name, + R.string.notification_poll_name, + R.string.notification_emoji_name, + R.string.notification_chat_message_name, + R.string.notification_subscription_name, + R.string.notification_move_name + }; + int[] channelDescriptions = { + R.string.notification_mention_descriptions, + R.string.notification_follow_description, + R.string.notification_follow_request_description, + R.string.notification_boost_description, + R.string.notification_favourite_description, + R.string.notification_poll_description, + R.string.notification_emoji_description, + R.string.notification_chat_message_description, + R.string.notification_subscription_description, + R.string.notification_move_description + }; + + List channels = new ArrayList<>(9); + + NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); + + //noinspection ConstantConditions + notificationManager.createNotificationChannelGroup(channelGroup); + + for (int i = 0; i < channelIds.length; i++) { + String id = channelIds[i]; + String name = context.getString(channelNames[i]); + String description = context.getString(channelDescriptions[i]); + int importance = NotificationManager.IMPORTANCE_DEFAULT; + NotificationChannel channel = new NotificationChannel(id, name, importance); + + channel.setDescription(description); + channel.enableLights(true); + channel.setLightColor(0xFF2B90D9); + channel.enableVibration(true); + channel.setShowBadge(true); + channel.setGroup(account.getIdentifier()); + channels.add(channel); + } + + notificationManager.createNotificationChannels(channels); + + } + } + + public static void deleteNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { + if (NOTIFICATION_USE_CHANNELS) { + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + //noinspection ConstantConditions + notificationManager.deleteNotificationChannelGroup(account.getIdentifier()); + + } + } + + public static void deleteLegacyNotificationChannels(@NonNull Context context, @NonNull AccountManager accountManager) { + if (NOTIFICATION_USE_CHANNELS) { + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + // used until Tusky 1.4 + //noinspection ConstantConditions + notificationManager.deleteNotificationChannel(CHANNEL_MENTION); + notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE); + notificationManager.deleteNotificationChannel(CHANNEL_BOOST); + notificationManager.deleteNotificationChannel(CHANNEL_FOLLOW); + + // used until Tusky 1.7 + for(AccountEntity account: accountManager.getAllAccountsOrderedByActive()) { + notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE+" "+account.getIdentifier()); + } + } + } + + public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) { + if (NOTIFICATION_USE_CHANNELS) { + + // on Android >= O, notifications are enabled, if at least one channel is enabled + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + //noinspection ConstantConditions + if (notificationManager.areNotificationsEnabled()) { + for (NotificationChannel channel : notificationManager.getNotificationChannels()) { + if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { + Log.d(TAG, "NotificationsEnabled"); + return true; + } + } + } + Log.d(TAG, "NotificationsDisabled"); + + return false; + + } else { + // on Android < O, notifications are enabled, if at least one account has notification enabled + return accountManager.areNotificationsEnabled(); + } + + } + + public static void enablePullNotifications(Context context) { + WorkManager workManager = WorkManager.getInstance(context); + workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG); + + WorkRequest workRequest = new PeriodicWorkRequest.Builder( + NotificationWorker.class, + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS, + PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS + ) + .addTag(NOTIFICATION_PULL_TAG) + .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build(); + + workManager.enqueue(workRequest); + + Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval"); + } + + public static void disablePullNotifications(Context context) { + WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG); + Log.d(TAG, "disabled notification checks"); + } + + public static void clearNotificationsForActiveAccount(@NonNull Context context, @NonNull AccountManager accountManager) { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null && !account.getActiveNotifications().equals("[]")) { + Single.fromCallable(() -> { + account.setActiveNotifications("[]"); + accountManager.saveAccount(account); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + //noinspection ConstantConditions + notificationManager.cancel((int) account.getId()); + return true; + }) + .subscribeOn(Schedulers.io()) + .subscribe(); + } + } + + private static boolean filterNotification(AccountEntity account, Notification notification, + Context context) { + + if (NOTIFICATION_USE_CHANNELS) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + String channelId = getChannelId(account, notification); + if(channelId == null) { + // unknown notificationtype + return false; + } + //noinspection ConstantConditions + NotificationChannel channel = notificationManager.getNotificationChannel(channelId); + return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; + } + + switch (notification.getType()) { + case MENTION: + return account.getNotificationsMentioned(); + case STATUS: + return account.getNotificationsSubscriptions(); + case FOLLOW: + return account.getNotificationsFollowed(); + case FOLLOW_REQUEST: + return account.getNotificationsFollowRequested(); + case REBLOG: + return account.getNotificationsReblogged(); + case FAVOURITE: + return account.getNotificationsFavorited(); + case POLL: + return account.getNotificationsPolls(); + case EMOJI_REACTION: + return account.getNotificationsEmojiReactions(); + case CHAT_MESSAGE: + return account.getNotificationsChatMessages(); + case MOVE: + return account.getNotificationsMove(); + default: + return false; + } + } + + @Nullable + private static String getChannelId(AccountEntity account, Notification notification) { + switch (notification.getType()) { + case MENTION: + return CHANNEL_MENTION + account.getIdentifier(); + case STATUS: + return CHANNEL_SUBSCRIPTIONS + account.getIdentifier(); + case FOLLOW: + return CHANNEL_FOLLOW + account.getIdentifier(); + case FOLLOW_REQUEST: + return CHANNEL_FOLLOW_REQUEST + account.getIdentifier(); + case REBLOG: + return CHANNEL_BOOST + account.getIdentifier(); + case FAVOURITE: + return CHANNEL_FAVOURITE + account.getIdentifier(); + case POLL: + return CHANNEL_POLL + account.getIdentifier(); + case EMOJI_REACTION: + return CHANNEL_EMOJI_REACTION + account.getIdentifier(); + case CHAT_MESSAGE: + return CHANNEL_CHAT_MESSAGES + account.getIdentifier(); + case MOVE: + return CHANNEL_MOVE + account.getIdentifier(); + default: + return null; + } + + } + + private static void setupPreferences(AccountEntity account, + NotificationCompat.Builder builder) { + + if (NOTIFICATION_USE_CHANNELS) { + return; //do nothing on Android O or newer, the system uses the channel settings anyway + } + + if (account.getNotificationSound()) { + builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); + } + + if (account.getNotificationVibration()) { + builder.setVibrate(new long[]{500, 500}); + } + + if (account.getNotificationLight()) { + builder.setLights(0xFF2B90D9, 300, 1000); + } + } + + private static String wrapItemAt(JSONArray array, int index) throws JSONException { + return StringUtils.unicodeWrap(array.get(index).toString()); + } + + @Nullable + private static String joinNames(Context context, JSONArray array) throws JSONException { + if (array.length() > 3) { + int length = array.length(); + return String.format(context.getString(R.string.notification_summary_large), + wrapItemAt(array, length - 1), + wrapItemAt(array, length - 2), + wrapItemAt(array, length - 3), + length - 3); + } else if (array.length() == 3) { + return String.format(context.getString(R.string.notification_summary_medium), + wrapItemAt(array, 2), + wrapItemAt(array, 1), + wrapItemAt(array, 0)); + } else if (array.length() == 2) { + return String.format(context.getString(R.string.notification_summary_small), + wrapItemAt(array, 1), + wrapItemAt(array, 0)); + } + + return null; + } + + @Nullable + private static String titleForType(Context context, Notification notification, AccountEntity account) { + String accountName = StringUtils.unicodeWrap(notification.getAccount().getName()); + switch (notification.getType()) { + case MENTION: + return String.format(context.getString(R.string.notification_mention_format), + accountName); + case STATUS: + return String.format(context.getString(R.string.notification_subscription_format), + accountName); + case FOLLOW: + return String.format(context.getString(R.string.notification_follow_format), + accountName); + case FOLLOW_REQUEST: + return String.format(context.getString(R.string.notification_follow_request_format), + accountName); + case FAVOURITE: + return String.format(context.getString(R.string.notification_favourite_format), + accountName); + case REBLOG: + return String.format(context.getString(R.string.notification_reblog_format), + accountName); + case EMOJI_REACTION: + return String.format(context.getString(R.string.notification_emoji_format), + accountName, notification.getEmoji()); + case POLL: + if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) { + return context.getString(R.string.poll_ended_created); + } else { + return context.getString(R.string.poll_ended_voted); + } + case CHAT_MESSAGE: + return String.format(context.getString(R.string.notification_chat_message_format), + accountName); + case MOVE: { + return String.format(context.getString(R.string.notification_move_format), accountName); + } + } + return null; + } + + private static String bodyForType(Notification notification, Context context) { + switch (notification.getType()) { + case MOVE: + return "@" + notification.getTarget().getUsername(); + case FOLLOW: + case FOLLOW_REQUEST: + return "@" + notification.getAccount().getUsername(); + case MENTION: + case FAVOURITE: + case REBLOG: + case EMOJI_REACTION: + case STATUS: + if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { + return notification.getStatus().getSpoilerText(); + } else { + return notification.getStatus().getContent().toString(); + } + case POLL: + if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { + return notification.getStatus().getSpoilerText(); + } else { + StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); + builder.append('\n'); + Poll poll = notification.getStatus().getPoll(); + for(PollOption option: poll.getOptions()) { + builder.append(buildDescription(option.getTitle(), + PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()), + context)); + builder.append('\n'); + } + return builder.toString(); + } + case CHAT_MESSAGE: + if (!TextUtils.isEmpty(notification.getChatMessage().getContent())) { + return notification.getChatMessage().getContent().toString(); + } else if(notification.getChatMessage().getAttachment() != null) { + return context.getString(notification.getChatMessage().getAttachment().describeAttachmentType()); + } else if(notification.getChatMessage().getCard() != null) { + return context.getString(R.string.link); + } else { + return ""; + } + } + return null; + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt new file mode 100644 index 0000000..ae7d4d3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt @@ -0,0 +1,51 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is part of Tusky. + * + * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU + * Lesser General Public License as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Tusky 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 Lesser + * General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License along with Tusky. If + * not, see . */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.Worker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import javax.inject.Inject + +class NotificationWorker( + context: Context, + params: WorkerParameters, + private val notificationsFetcher: NotificationFetcher +) : Worker(context, params) { + + override fun doWork(): Result { + notificationsFetcher.fetchAndShow() + return Result.success() + } +} + +class NotificationWorkerFactory @Inject constructor( + private val notificationsFetcher: NotificationFetcher +) : WorkerFactory() { + + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + if (workerClassName == NotificationWorker::class.java.name) { + return NotificationWorker(appContext, workerParameters, notificationsFetcher) + } + return null + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt new file mode 100644 index 0000000..35c33a9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt @@ -0,0 +1,20 @@ +package com.keylesspalace.tusky.components.notifications + +import android.content.Context +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.entity.Notification + +/** + * Shows notifications. + */ +interface Notifier { + fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) +} + +class SystemNotifier( + private val context: Context +) : Notifier { + override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) { + NotificationHelper.make(context, notification, account, isFirstInBatch) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt new file mode 100644 index 0000000..5001472 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -0,0 +1,390 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.preference + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.instancemute.InstanceListActivity +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.* +import com.keylesspalace.tusky.util.ThemeUtils +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeRes +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import javax.inject.Inject + +class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var mastodonApi: MastodonApi + + @Inject + lateinit var eventHub: EventHub + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val context = requireContext() + makePreferenceScreen { + preference { + setTitle(R.string.pref_title_edit_notification_settings) + icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).apply { + sizeRes = R.dimen.preference_icon_size + colorInt = ThemeUtils.getColor(context, R.attr.iconColor) + } + setOnPreferenceClickListener { + openNotificationPrefs() + true + } + } + + preference { + setTitle(R.string.title_tab_preferences) + setIcon(R.drawable.ic_tabs) + setOnPreferenceClickListener { + val intent = Intent(context, TabPreferenceActivity::class.java) + activity?.startActivity(intent) + activity?.overridePendingTransition(R.anim.slide_from_right, + R.anim.slide_to_left) + true + } + } + + preference { + setTitle(R.string.action_view_mutes) + setIcon(R.drawable.ic_mute_24dp) + setOnPreferenceClickListener { + val intent = Intent(context, AccountListActivity::class.java) + intent.putExtra("type", AccountListActivity.Type.MUTES) + activity?.startActivity(intent) + activity?.overridePendingTransition(R.anim.slide_from_right, + R.anim.slide_to_left) + true + } + } + + preference { + setTitle(R.string.action_view_blocks) + icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply { + sizeRes = R.dimen.preference_icon_size + colorInt = ThemeUtils.getColor(context, R.attr.iconColor) + } + setOnPreferenceClickListener { + val intent = Intent(context, AccountListActivity::class.java) + intent.putExtra("type", AccountListActivity.Type.BLOCKS) + activity?.startActivity(intent) + activity?.overridePendingTransition(R.anim.slide_from_right, + R.anim.slide_to_left) + true + } + } + + preference { + setTitle(R.string.title_domain_mutes) + setIcon(R.drawable.ic_mute_24dp) + setOnPreferenceClickListener { + val intent = Intent(context, InstanceListActivity::class.java) + activity?.startActivity(intent) + activity?.overridePendingTransition(R.anim.slide_from_right, + R.anim.slide_to_left) + true + } + } + + preferenceCategory(R.string.pref_publishing) { + listPreference { + setTitle(R.string.pref_default_post_privacy) + setEntries(R.array.post_privacy_names) + setEntryValues(R.array.post_privacy_values) + key = PrefKeys.DEFAULT_POST_PRIVACY + setSummaryProvider { entry } + val visibility = accountManager.activeAccount?.defaultPostPrivacy + ?: Status.Visibility.PUBLIC + value = visibility.serverString() + setIcon(getIconForVisibility(visibility)) + setOnPreferenceChangeListener { _, newValue -> + setIcon(getIconForVisibility(Status.Visibility.byString(newValue as String))) + syncWithServer(visibility = newValue) + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + + listPreference { + setTitle(R.string.pref_title_default_formatting) + setEntries(R.array.formatting_syntax_values) + setEntryValues(R.array.formatting_syntax_values) + key = PrefKeys.DEFAULT_FORMATTING_SYNTAX + setSummaryProvider { entry } + val syntax = accountManager.activeAccount?.defaultFormattingSyntax + ?: "" + value = when(syntax) { + "text/markdown" -> "Markdown" + "text/bbcode" -> "BBCode" + "text/html" -> "HTML" + else -> "Plaintext" + } + setIcon(getIconForSyntax(syntax)) + setOnPreferenceChangeListener { _, newValue -> + val syntax = when(newValue) { + "Markdown" -> "text/markdown" + "BBCode" -> "text/bbcode" + "HTML" -> "text/html" + else -> "" + } + setIcon(getIconForSyntax(syntax)) + updateAccount { it.defaultFormattingSyntax = syntax } + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + + } + + switchPreference { + setTitle(R.string.pref_default_media_sensitivity) + setIcon(R.drawable.ic_eye_24dp) + key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY + isSingleLineTitle = false + val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity + ?: false + setDefaultValue(sensitivity) + setIcon(getIconForSensitivity(sensitivity)) + setOnPreferenceChangeListener { _, newValue -> + setIcon(getIconForSensitivity(newValue as Boolean)) + syncWithServer(sensitive = newValue) + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + } + + preferenceCategory(R.string.pref_title_timelines) { + switchPreference { + key = PrefKeys.MEDIA_PREVIEW_ENABLED + setTitle(R.string.pref_title_show_media_preview) + isSingleLineTitle = false + isChecked = accountManager.activeAccount?.mediaPreviewEnabled ?: true + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.mediaPreviewEnabled = newValue as Boolean } + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + + switchPreference { + key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA + setTitle(R.string.pref_title_alway_show_sensitive_media) + isSingleLineTitle = false + isChecked = accountManager.activeAccount?.alwaysShowSensitiveMedia ?: false + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.alwaysShowSensitiveMedia = newValue as Boolean } + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + + switchPreference { + key = PrefKeys.ALWAYS_OPEN_SPOILER + setTitle(R.string.pref_title_alway_open_spoiler) + isSingleLineTitle = false + isChecked = accountManager.activeAccount?.alwaysOpenSpoiler ?: false + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.alwaysOpenSpoiler = newValue as Boolean } + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + } + + preferenceCategory(R.string.pref_title_other) { + switchPreference { + key = PrefKeys.LIVE_NOTIFICATIONS + setTitle(R.string.pref_title_live_notifications) + setSummary(R.string.pref_summary_live_notifications) + isSingleLineTitle = false + isChecked = accountManager.activeAccount?.notificationsStreamingEnabled ?: false + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsStreamingEnabled = newValue as Boolean } + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + } + + preferenceCategory(R.string.pref_title_timeline_filters) { + preference { + setTitle(R.string.pref_title_public_filter_keywords) + setOnPreferenceClickListener { + launchFilterActivity(Filter.PUBLIC, + R.string.pref_title_public_filter_keywords) + true + } + } + + preference { + setTitle(R.string.title_notifications) + setOnPreferenceClickListener { + launchFilterActivity(Filter.NOTIFICATIONS, R.string.title_notifications) + true + } + } + + preference { + setTitle(R.string.title_home) + setOnPreferenceClickListener { + launchFilterActivity(Filter.HOME, R.string.title_home) + true + } + } + + preference { + setTitle(R.string.pref_title_thread_filter_keywords) + setOnPreferenceClickListener { + launchFilterActivity(Filter.THREAD, + R.string.pref_title_thread_filter_keywords) + true + } + } + + preference { + setTitle(R.string.title_accounts) + setOnPreferenceClickListener { + launchFilterActivity(Filter.ACCOUNT, R.string.title_accounts) + true + } + } + } + } + } + + private fun openNotificationPrefs() { + if (NotificationHelper.NOTIFICATION_USE_CHANNELS) { + val intent = Intent() + intent.action = "android.settings.APP_NOTIFICATION_SETTINGS" + intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID) + startActivity(intent) + } else { + activity?.let { + val intent = PreferencesActivity.newIntent(it, PreferencesActivity.NOTIFICATION_PREFERENCES) + it.startActivity(intent) + it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + + } + } + + private inline fun updateAccount(changer: (AccountEntity) -> Unit) { + accountManager.activeAccount?.let { account -> + changer(account) + accountManager.saveAccount(account) + } + } + + private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { + mastodonApi.accountUpdateSource(visibility, sensitive) + .enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + val account = response.body() + if (response.isSuccessful && account != null) { + + accountManager.activeAccount?.let { + it.defaultPostPrivacy = account.source?.privacy + ?: Status.Visibility.PUBLIC + it.defaultMediaSensitivity = account.source?.sensitive ?: false + accountManager.saveAccount(it) + } + } else { + Log.e("AccountPreferences", "failed updating settings on server") + showErrorSnackbar(visibility, sensitive) + } + } + + override fun onFailure(call: Call, t: Throwable) { + Log.e("AccountPreferences", "failed updating settings on server", t) + showErrorSnackbar(visibility, sensitive) + } + + }) + } + + private fun showErrorSnackbar(visibility: String?, sensitive: Boolean?) { + view?.let { view -> + Snackbar.make(view, R.string.pref_failed_to_sync, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { syncWithServer(visibility, sensitive) } + .show() + } + } + + @DrawableRes + private fun getIconForVisibility(visibility: Status.Visibility): Int { + return when (visibility) { + Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp + + Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp + + else -> R.drawable.ic_public_24dp + } + } + + @DrawableRes + private fun getIconForSensitivity(sensitive: Boolean): Int { + return if (sensitive) { + R.drawable.ic_hide_media_24dp + } else { + R.drawable.ic_eye_24dp + } + } + + private fun getIconForSyntax(syntax: String): Int { + return when(syntax) { + "text/html" -> R.drawable.ic_html_24dp + "text/bbcode" -> R.drawable.ic_bbcode_24dp + "text/markdown" -> R.drawable.ic_markdown + else -> android.R.color.transparent + } + } + + private fun launchFilterActivity(filterContext: String, titleResource: Int) { + val intent = Intent(context, FiltersActivity::class.java) + intent.putExtra(FiltersActivity.FILTERS_CONTEXT, filterContext) + intent.putExtra(FiltersActivity.FILTERS_TITLE, getString(titleResource)) + activity?.startActivity(intent) + activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + + companion object { + fun newInstance() = AccountPreferencesFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt new file mode 100644 index 0000000..c0e538a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt @@ -0,0 +1,258 @@ +package com.keylesspalace.tusky.components.preference + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.SplashActivity +import com.keylesspalace.tusky.util.EmojiCompatFont +import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import okhttp3.OkHttpClient +import kotlin.system.exitProcess + +/** + * This Preference lets the user select their preferred emoji font + */ +class EmojiPreference( + context: Context, + private val okHttpClient: OkHttpClient +) : Preference(context) { + + private lateinit var selected: EmojiCompatFont + private lateinit var original: EmojiCompatFont + private val radioButtons = mutableListOf() + private var updated = false + private var currentNeedsUpdate = false + + private val downloadDisposables = MutableList(FONTS.size) { null } + + override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) { + super.onAttachedToHierarchy(preferenceManager) + + // Find out which font is currently active + selected = EmojiCompatFont.byId( + PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) + ) + // We'll use this later to determine if anything has changed + original = selected + summary = selected.getDisplay(context) + } + + override fun onClick() { + val view = LayoutInflater.from(context).inflate(R.layout.dialog_emojicompat, null) + viewIds.forEachIndexed { index, viewId -> + setupItem(view.findViewById(viewId), FONTS[index]) + } + AlertDialog.Builder(context) + .setView(view) + .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun setupItem(container: View, font: EmojiCompatFont) { + val title: TextView = container.findViewById(R.id.emojicompat_name) + val caption: TextView = container.findViewById(R.id.emojicompat_caption) + val thumb: ImageView = container.findViewById(R.id.emojicompat_thumb) + val download: ImageButton = container.findViewById(R.id.emojicompat_download) + val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel) + val radio: RadioButton = container.findViewById(R.id.emojicompat_radio) + + // Initialize all the views + title.text = font.getDisplay(container.context) + caption.setText(font.caption) + thumb.setImageResource(font.img) + + // There needs to be a list of all the radio buttons in order to uncheck them when one is selected + radioButtons.add(radio) + updateItem(font, container) + + // Set actions + download.setOnClickListener { startDownload(font, container) } + cancel.setOnClickListener { cancelDownload(font, container) } + radio.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) } + container.setOnClickListener { containerView: View -> + select(font, containerView.findViewById(R.id.emojicompat_radio)) + } + } + + private fun startDownload(font: EmojiCompatFont, container: View) { + val download: ImageButton = container.findViewById(R.id.emojicompat_download) + val caption: TextView = container.findViewById(R.id.emojicompat_caption) + val progressBar: ProgressBar = container.findViewById(R.id.emojicompat_progress) + val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel) + + // Switch to downloading style + download.visibility = View.GONE + caption.visibility = View.INVISIBLE + progressBar.visibility = View.VISIBLE + progressBar.progress = 0 + cancel.visibility = View.VISIBLE + font.downloadFontFile(context, okHttpClient) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { progress -> + // The progress is returned as a float between 0 and 1, or -1 if it could not determined + if (progress >= 0) { + progressBar.isIndeterminate = false + val max = progressBar.max.toFloat() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + progressBar.setProgress((max * progress).toInt(), true) + } else { + progressBar.progress = (max * progress).toInt() + } + } else { + progressBar.isIndeterminate = true + } + }, + { + Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show() + updateItem(font, container) + }, + { + finishDownload(font, container) + } + ).also { downloadDisposables[font.id] = it } + + + } + + private fun cancelDownload(font: EmojiCompatFont, container: View) { + font.deleteDownloadedFile(container.context) + downloadDisposables[font.id]?.dispose() + downloadDisposables[font.id] = null + updateItem(font, container) + } + + private fun finishDownload(font: EmojiCompatFont, container: View) { + select(font, container.findViewById(R.id.emojicompat_radio)) + updateItem(font, container) + // Set the flag to restart the app (because an update has been downloaded) + if (selected === original && currentNeedsUpdate) { + updated = true + currentNeedsUpdate = false + } + } + + /** + * Select a font both visually and logically + * + * @param font The font to be selected + * @param radio The radio button associated with it's visual item + */ + private fun select(font: EmojiCompatFont, radio: RadioButton) { + selected = font + // Uncheck all the other buttons + for (other in radioButtons) { + if (other !== radio) { + other.isChecked = false + } + } + radio.isChecked = true + } + + /** + * Called when a "consistent" state is reached, i.e. it's not downloading the font + * + * @param font The font to be displayed + * @param container The ConstraintLayout containing the item + */ + private fun updateItem(font: EmojiCompatFont, container: View) { + // Assignments + val download: ImageButton = container.findViewById(R.id.emojicompat_download) + val caption: TextView = container.findViewById(R.id.emojicompat_caption) + val progress: ProgressBar = container.findViewById(R.id.emojicompat_progress) + val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel) + val radio: RadioButton = container.findViewById(R.id.emojicompat_radio) + + // There's no download going on + progress.visibility = View.GONE + cancel.visibility = View.GONE + caption.visibility = View.VISIBLE + if (font.isDownloaded(context)) { + // Make it selectable + download.visibility = View.GONE + radio.visibility = View.VISIBLE + container.isClickable = true + } else { + // Make it downloadable + download.visibility = View.VISIBLE + radio.visibility = View.GONE + container.isClickable = false + } + + // Select it if necessary + if (font === selected) { + radio.isChecked = true + // Update available + if (!font.isDownloaded(context)) { + currentNeedsUpdate = true + } + } else { + radio.isChecked = false + } + } + + private fun saveSelectedFont() { + val index = selected.id + Log.i(TAG, "saveSelectedFont: Font ID: $index") + PreferenceManager + .getDefaultSharedPreferences(context) + .edit() + .putInt(key, index) + .apply() + summary = selected.getDisplay(context) + } + + /** + * User clicked ok -> save the selected font and offer to restart the app if something changed + */ + private fun onDialogOk() { + saveSelectedFont() + if (selected !== original || updated) { + AlertDialog.Builder(context) + .setTitle(R.string.restart_required) + .setMessage(R.string.restart_emoji) + .setNegativeButton(R.string.later, null) + .setPositiveButton(R.string.restart) { _, _ -> + // Restart the app + // From https://stackoverflow.com/a/17166729/5070653 + val launchIntent = Intent(context, SplashActivity::class.java) + val mPendingIntent = PendingIntent.getActivity( + context, + 0x1f973, // This is the codepoint of the party face emoji :D + launchIntent, + PendingIntent.FLAG_CANCEL_CURRENT) + val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + mgr.set( + AlarmManager.RTC, + System.currentTimeMillis() + 100, + mPendingIntent) + exitProcess(0) + }.show() + } + } + + companion object { + private const val TAG = "EmojiPreference" + + // Please note that this array must sorted in the same way as the fonts. + private val viewIds = intArrayOf( + R.id.item_nomoji, + R.id.item_blobmoji, + R.id.item_twemoji, + R.id.item_notoemoji + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt new file mode 100644 index 0000000..935c47e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -0,0 +1,213 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.preference + +import android.os.Bundle +import androidx.preference.PreferenceFragmentCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.switchPreference +import javax.inject.Inject + +class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { + + @Inject + lateinit var accountManager: AccountManager + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val activeAccount = accountManager.activeAccount ?: return + val context = requireContext() + makePreferenceScreen { + switchPreference { + setTitle(R.string.pref_title_notifications_enabled) + key = PrefKeys.NOTIFICATIONS_ENABLED + isIconSpaceReserved = false + isChecked = activeAccount.notificationsEnabled + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsEnabled = newValue as Boolean } + if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { + NotificationHelper.enablePullNotifications(context) + } else { + NotificationHelper.disablePullNotifications(context) + } + true + } + } + + preferenceCategory(R.string.pref_title_notification_filters) { category -> + category.dependency = PrefKeys.NOTIFICATIONS_ENABLED + category.isIconSpaceReserved = false + + switchPreference { + setTitle(R.string.pref_title_notification_filter_follows) + key = PrefKeys.NOTIFICATIONS_FILTER_FOLLOWS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsFollowed + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsFollowed = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_follow_requests) + key = PrefKeys.NOTIFICATION_FILTER_FOLLOW_REQUESTS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsFollowRequested + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsFollowRequested = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_reblogs) + key = PrefKeys.NOTIFICATION_FILTER_REBLOGS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsReblogged + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsReblogged = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_favourites) + key = PrefKeys.NOTIFICATION_FILTER_FAVS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsFavorited + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsFavorited = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_emoji) + key = PrefKeys.NOTIFICATION_FILTER_EMOJI_REACTIONS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsEmojiReactions + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsEmojiReactions = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_poll) + key = PrefKeys.NOTIFICATION_FILTER_POLLS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsPolls + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsPolls = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_chat_messages) + key = PrefKeys.NOTIFICATION_FILTER_CHAT_MESSAGES + isIconSpaceReserved = false + isChecked = activeAccount.notificationsChatMessages + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsChatMessages = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_subscriptions) + key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsSubscriptions + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsSubscriptions = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_move) + key = PrefKeys.NOTIFICATION_FILTER_MOVE + isIconSpaceReserved = false + isChecked = activeAccount.notificationsMove + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsMove = newValue as Boolean } + true + } + } + } + + preferenceCategory(R.string.pref_title_notification_alerts) { category -> + category.dependency = PrefKeys.NOTIFICATIONS_ENABLED + category.isIconSpaceReserved = false + + switchPreference { + setTitle(R.string.pref_title_notification_alert_sound) + key = PrefKeys.NOTIFICATION_ALERT_SOUND + isIconSpaceReserved = false + isChecked = activeAccount.notificationSound + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationSound = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_alert_vibrate) + key = PrefKeys.NOTIFICATION_ALERT_VIBRATE + isIconSpaceReserved = false + isChecked = activeAccount.notificationVibration + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationVibration = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_alert_light) + key = PrefKeys.NOTIFICATION_ALERT_LIGHT + isIconSpaceReserved = false + isChecked = activeAccount.notificationLight + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationLight = newValue as Boolean } + true + } + } + } + } + } + + private inline fun updateAccount(changer: (AccountEntity) -> Unit) { + accountManager.activeAccount?.let { account -> + changer(account) + accountManager.saveAccount(account) + } + } + + companion object { + fun newInstance(): NotificationPreferencesFragment { + return NotificationPreferencesFragment() + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt new file mode 100644 index 0000000..8618711 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -0,0 +1,192 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.preference + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.util.Log +import android.view.MenuItem +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.getNonNullString +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener, + HasAndroidInjector { + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + private var restartActivitiesOnExit: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_preferences) + + setSupportActionBar(toolbar) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + val fragment: Fragment = when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { + GENERAL_PREFERENCES -> { + setTitle(R.string.action_view_preferences) + PreferencesFragment.newInstance() + } + ACCOUNT_PREFERENCES -> { + setTitle(R.string.action_view_account_preferences) + AccountPreferencesFragment.newInstance() + } + NOTIFICATION_PREFERENCES -> { + setTitle(R.string.pref_title_edit_notification_settings) + NotificationPreferencesFragment.newInstance() + } + TAB_FILTER_PREFERENCES -> { + setTitle(R.string.pref_title_status_tabs) + TabFilterPreferencesFragment.newInstance() + } + PROXY_PREFERENCES -> { + setTitle(R.string.pref_title_http_proxy_settings) + ProxyPreferencesFragment.newInstance() + } + else -> throw IllegalArgumentException("preferenceType not known") + } + + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, fragment) + .commit() + + restartActivitiesOnExit = intent.getBooleanExtra("restart", false) + + } + + override fun onResume() { + super.onResume() + PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + super.onPause() + PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(this) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun saveInstanceState(outState: Bundle) { + outState.putBoolean("restart", restartActivitiesOnExit) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean("restart", restartActivitiesOnExit) + super.onSaveInstanceState(outState) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + when (key) { + "appTheme" -> { + val theme = sharedPreferences.getNonNullString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + Log.d("activeTheme", theme) + ThemeUtils.setAppNightMode(theme) + + restartActivitiesOnExit = true + this.restartCurrentActivity() + + } + "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", + "useBlurhash", "showCardsInTimelines", "confirmReblogs", + "enableSwipeForTabs", "bigEmojis", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, + PrefKeys.RENDER_STATUS_AS_MENTION -> { + restartActivitiesOnExit = true + } + "language" -> { + restartActivitiesOnExit = true + this.restartCurrentActivity() + } + } + + eventHub.dispatch(PreferenceChangedEvent(key)) + } + + private fun restartCurrentActivity() { + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + val savedInstanceState = Bundle() + saveInstanceState(savedInstanceState) + intent.putExtras(savedInstanceState) + startActivityWithSlideInAnimation(intent) + finish() + overridePendingTransition(R.anim.fade_in, R.anim.fade_out) + } + + override fun onBackPressed() { + /* Switching themes won't actually change the theme of activities on the back stack. + * Either the back stack activities need to all be recreated, or do the easier thing, which + * is hijack the back button press and use it to launch a new MainActivity and clear the + * back stack. */ + if (restartActivitiesOnExit) { + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivityWithSlideInAnimation(intent) + } else { + super.onBackPressed() + } + } + + override fun androidInjector() = androidInjector + + companion object { + + const val GENERAL_PREFERENCES = 0 + const val ACCOUNT_PREFERENCES = 1 + const val NOTIFICATION_PREFERENCES = 2 + const val TAB_FILTER_PREFERENCES = 3 + const val PROXY_PREFERENCES = 4 + private const val EXTRA_PREFERENCE_TYPE = "EXTRA_PREFERENCE_TYPE" + + @JvmStatic + fun newIntent(context: Context, preferenceType: Int): Intent { + val intent = Intent(context, PreferencesActivity::class.java) + intent.putExtra(EXTRA_PREFERENCE_TYPE, preferenceType) + return intent + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt new file mode 100644 index 0000000..8b3f151 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -0,0 +1,340 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.preference + +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.settings.* +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.serialize +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizePx +import okhttp3.OkHttpClient +import javax.inject.Inject + +class PreferencesFragment : PreferenceFragmentCompat(), Injectable { + + @Inject + lateinit var okhttpclient: OkHttpClient + + @Inject + lateinit var accountManager: AccountManager + + private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + private var httpProxyPref: Preference? = null + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + makePreferenceScreen { + preferenceCategory(R.string.pref_title_appearance_settings) { + listPreference { + setDefaultValue(AppTheme.NIGHT.value) + setEntries(R.array.app_theme_names) + entryValues = AppTheme.stringValues() + key = PrefKeys.APP_THEME + setSummaryProvider { entry } + setTitle(R.string.pref_title_app_theme) + icon = makeIcon(GoogleMaterial.Icon.gmd_palette) + } + + emojiPreference(okhttpclient) { + setDefaultValue("system_default") + setIcon(R.drawable.ic_emoji_24dp) + key = PrefKeys.EMOJI + setSummary(R.string.system_default) + setTitle(R.string.emoji_style) + icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied) + } + + listPreference { + setDefaultValue("default") + setEntries(R.array.language_entries) + setEntryValues(R.array.language_values) + key = PrefKeys.LANGUAGE + setSummaryProvider { entry } + setTitle(R.string.pref_title_language) + icon = makeIcon(GoogleMaterial.Icon.gmd_translate) + } + + listPreference { + setDefaultValue("medium") + setEntries(R.array.status_text_size_names) + setEntryValues(R.array.status_text_size_values) + key = PrefKeys.STATUS_TEXT_SIZE + setSummaryProvider { entry } + setTitle(R.string.pref_status_text_size) + icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) + } + + listPreference { + setDefaultValue("top") + setEntries(R.array.pref_main_nav_position_options) + setEntryValues(R.array.pref_main_nav_position_values) + key = PrefKeys.MAIN_NAV_POSITION + setSummaryProvider { entry } + setTitle(R.string.pref_main_nav_position) + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.HIDE_TOP_TOOLBAR + setTitle(R.string.pref_title_hide_top_toolbar) + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.FAB_HIDE + setTitle(R.string.pref_title_hide_follow_button) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.ABSOLUTE_TIME_VIEW + setTitle(R.string.pref_title_absolute_time) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.SHOW_BOT_OVERLAY + setTitle(R.string.pref_title_bot_overlay) + isSingleLineTitle = false + setIcon(R.drawable.ic_bot_24dp) + + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.ANIMATE_GIF_AVATARS + setTitle(R.string.pref_title_animate_gif_avatars) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.USE_BLURHASH + setTitle(R.string.pref_title_gradient_for_media) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.SHOW_CARDS_IN_TIMELINES + setTitle(R.string.pref_title_show_cards_in_timelines) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.SHOW_NOTIFICATIONS_FILTER + setTitle(R.string.pref_title_show_notifications_filter) + isSingleLineTitle = false + setOnPreferenceClickListener { + activity?.let { activity -> + val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES) + activity.startActivity(intent) + activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + true + } + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.CONFIRM_REBLOGS + setTitle(R.string.pref_title_confirm_reblogs) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.HIDE_MUTED_USERS + setTitle(R.string.pref_title_hide_muted_users) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.ENABLE_SWIPE_FOR_TABS + setTitle(R.string.pref_title_enable_swipe_for_tabs) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.BIG_EMOJIS + setTitle(R.string.pref_title_enable_big_emojis) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.STICKERS + setTitle(R.string.pref_title_enable_experimental_stickers) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.ANIMATE_CUSTOM_EMOJIS + setTitle(R.string.pref_title_animate_custom_emojis) + isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(true) + key = PrefKeys.RENDER_STATUS_AS_MENTION + setTitle(R.string.pref_title_render_subscriptions_as_statuses) + isSingleLineTitle = true + } + } + + preferenceCategory(R.string.pref_title_privacy) { + switchPreference { + setDefaultValue(false) + key = PrefKeys.ANONYMIZE_FILENAMES + setTitle(R.string.pref_title_anonymize_upload_filenames) + isSingleLineTitle = false + } + } + + preferenceCategory(R.string.pref_title_browser_settings) { + switchPreference { + setDefaultValue(false) + key = PrefKeys.CUSTOM_TABS + setTitle(R.string.pref_title_custom_tabs) + isSingleLineTitle = false + } + } + + preferenceCategory(R.string.pref_title_timeline_filters) { + preference { + setTitle(R.string.pref_title_status_tabs) + setOnPreferenceClickListener { + activity?.let { activity -> + val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES) + activity.startActivity(intent) + activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + true + } + } + } + + preferenceCategory(R.string.pref_title_wellbeing_mode) { + switchPreference { + title = getString(R.string.limit_notifications) + setDefaultValue(false) + key = PrefKeys.WELLBEING_LIMITED_NOTIFICATIONS + setOnPreferenceChangeListener { _, value -> + for (account in accountManager.accounts) { + val notificationFilter = deserialize(account.notificationsFilter).toMutableSet() + + if (value == true) { + notificationFilter.add(Notification.Type.FAVOURITE) + notificationFilter.add(Notification.Type.FOLLOW) + notificationFilter.add(Notification.Type.REBLOG) + } else { + notificationFilter.remove(Notification.Type.FAVOURITE) + notificationFilter.remove(Notification.Type.FOLLOW) + notificationFilter.remove(Notification.Type.REBLOG) + } + + account.notificationsFilter = serialize(notificationFilter) + accountManager.saveAccount(account) + } + true + } + } + + switchPreference { + title = getString(R.string.wellbeing_hide_stats_posts) + setDefaultValue(false) + key = PrefKeys.WELLBEING_HIDE_STATS_POSTS + } + + switchPreference { + title = getString(R.string.wellbeing_hide_stats_profile) + setDefaultValue(false) + key = PrefKeys.WELLBEING_HIDE_STATS_PROFILE + } + } + + preferenceCategory(R.string.pref_title_proxy_settings) { + httpProxyPref = preference { + setTitle(R.string.pref_title_http_proxy_settings) + setOnPreferenceClickListener { + activity?.let { activity -> + val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.PROXY_PREFERENCES) + activity.startActivity(intent) + activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + true + } + } + } + } + } + + private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable { + val context = requireContext() + return IconicsDrawable(context, icon).apply { + sizePx = iconSize + colorInt = ThemeUtils.getColor(context, R.attr.iconColor) + } + + } + + override fun onResume() { + super.onResume() + updateHttpProxySummary() + } + + private fun updateHttpProxySummary() { + val sharedPreferences = preferenceManager.sharedPreferences + val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false) + val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "") + + try { + val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1") + .toInt() + + if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) { + httpProxyPref?.summary = "$httpServer:$httpPort" + return + } + } catch (e: NumberFormatException) { + // user has entered wrong port, fall back to empty summary + } + + httpProxyPref?.summary = "" + } + + companion object { + fun newInstance(): PreferencesFragment { + return PreferencesFragment() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt new file mode 100644 index 0000000..922d5a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -0,0 +1,69 @@ +/* Copyright 2018 Conny Duck + + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.preference + +import android.os.Bundle +import androidx.preference.PreferenceFragmentCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.editTextPreference +import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.switchPreference +import kotlin.system.exitProcess + +class ProxyPreferencesFragment : PreferenceFragmentCompat() { + private var pendingRestart = false + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + makePreferenceScreen { + switchPreference { + setTitle(R.string.pref_title_http_proxy_enable) + isIconSpaceReserved = false + key = PrefKeys.HTTP_PROXY_ENABLED + setDefaultValue(false) + } + + editTextPreference { + setTitle(R.string.pref_title_http_proxy_server) + key = PrefKeys.HTTP_PROXY_SERVER + isIconSpaceReserved = false + setSummaryProvider { text } + } + + editTextPreference { + setTitle(R.string.pref_title_http_proxy_port) + key = PrefKeys.HTTP_PROXY_PORT + isIconSpaceReserved = false + setSummaryProvider { text } + } + } + + } + + override fun onPause() { + super.onPause() + if (pendingRestart) { + pendingRestart = false + exitProcess(0) + } + } + + companion object { + fun newInstance(): ProxyPreferencesFragment { + return ProxyPreferencesFragment() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt new file mode 100644 index 0000000..71c5e10 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -0,0 +1,54 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.preference + +import android.os.Bundle +import androidx.preference.PreferenceFragmentCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.checkBoxPreference +import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.preferenceCategory + +class TabFilterPreferencesFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + makePreferenceScreen { + preferenceCategory(R.string.title_home) { category -> + category.isIconSpaceReserved = false + + checkBoxPreference { + setTitle(R.string.pref_title_show_boosts) + key = PrefKeys.TAB_FILTER_HOME_BOOSTS + setDefaultValue(true) + isIconSpaceReserved = false + } + + checkBoxPreference { + setTitle(R.string.pref_title_show_replies) + key = PrefKeys.TAB_FILTER_HOME_REPLIES + setDefaultValue(false) + isIconSpaceReserved = false + } + } + } + } + + companion object { + fun newInstance(): TabFilterPreferencesFragment { + return TabFilterPreferencesFragment() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt new file mode 100644 index 0000000..5daf584 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -0,0 +1,150 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.lifecycle.Observer +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter +import com.keylesspalace.tusky.di.ViewModelFactory +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import kotlinx.android.synthetic.main.activity_report.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + + +class ReportActivity : BottomSheetActivity(), HasAndroidInjector { + + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ReportViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val accountId = intent?.getStringExtra(ACCOUNT_ID) + val accountUserName = intent?.getStringExtra(ACCOUNT_USERNAME) + if (accountId.isNullOrBlank() || accountUserName.isNullOrBlank()) { + throw IllegalStateException("accountId ($accountId) or accountUserName ($accountUserName) is null") + } + + viewModel.init(accountId, accountUserName, intent?.getStringExtra(STATUS_ID)) + + + setContentView(R.layout.activity_report) + + setSupportActionBar(toolbar) + + supportActionBar?.apply { + title = getString(R.string.report_username_format, viewModel.accountUserName) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_close_24dp) + } + + initViewPager() + if (savedInstanceState == null) { + viewModel.navigateTo(Screen.Statuses) + } + subscribeObservables() + } + + private fun initViewPager() { + wizard.isUserInputEnabled = false + wizard.adapter = ReportPagerAdapter(this) + } + + private fun subscribeObservables() { + viewModel.navigation.observe(this, Observer { screen -> + if (screen != null) { + viewModel.navigated() + when (screen) { + Screen.Statuses -> showStatusesPage() + Screen.Note -> showNotesPage() + Screen.Done -> showDonePage() + Screen.Back -> showPreviousScreen() + Screen.Finish -> closeScreen() + } + } + }) + + viewModel.checkUrl.observe(this, Observer { + if (!it.isNullOrBlank()) { + viewModel.urlChecked() + viewUrl(it) + } + }) + } + + private fun showPreviousScreen() { + when (wizard.currentItem) { + 0 -> closeScreen() + 1 -> showStatusesPage() + } + } + + private fun showDonePage() { + wizard.currentItem = 2 + } + + private fun showNotesPage() { + wizard.currentItem = 1 + } + + private fun closeScreen() { + finish() + } + + private fun showStatusesPage() { + wizard.currentItem = 0 + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + closeScreen() + return true + } + } + return super.onOptionsItemSelected(item) + } + + companion object { + private const val ACCOUNT_ID = "account_id" + private const val ACCOUNT_USERNAME = "account_username" + private const val STATUS_ID = "status_id" + + @JvmStatic + fun getIntent(context: Context, accountId: String, userName: String, statusId: String? = null) = + Intent(context, ReportActivity::class.java) + .apply { + putExtra(ACCOUNT_ID, accountId) + putExtra(ACCOUNT_USERNAME, userName) + putExtra(STATUS_ID, statusId) + } + } + + override fun androidInjector() = androidInjector +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt new file mode 100644 index 0000000..8bb2e88 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -0,0 +1,225 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.paging.PagedList +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.components.report.adapter.StatusesRepository +import com.keylesspalace.tusky.components.report.model.StatusViewState +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class ReportViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub, + private val statusesRepository: StatusesRepository) : RxAwareViewModel() { + + private val navigationMutable = MutableLiveData() + val navigation: LiveData = navigationMutable + + private val muteStateMutable = MutableLiveData>() + val muteState: LiveData> = muteStateMutable + + private val blockStateMutable = MutableLiveData>() + val blockState: LiveData> = blockStateMutable + + private val reportingStateMutable = MutableLiveData>() + var reportingState: LiveData> = reportingStateMutable + + private val checkUrlMutable = MutableLiveData() + val checkUrl: LiveData = checkUrlMutable + + private val repoResult = MutableLiveData>() + val statuses: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } + val networkStateAfter: LiveData = Transformations.switchMap(repoResult) { it.networkStateAfter } + val networkStateBefore: LiveData = Transformations.switchMap(repoResult) { it.networkStateBefore } + val networkStateRefresh: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + + private val selectedIds = HashSet() + val statusViewState = StatusViewState() + + var reportNote: String = "" + var isRemoteNotify = false + + private var statusId: String? = null + lateinit var accountUserName: String + lateinit var accountId: String + var isRemoteAccount: Boolean = false + var remoteServer: String? = null + + fun init(accountId: String, userName: String, statusId: String?) { + this.accountId = accountId + this.accountUserName = userName + this.statusId = statusId + statusId?.let { + selectedIds.add(it) + } + + isRemoteAccount = userName.contains('@') + if (isRemoteAccount) { + remoteServer = userName.substring(userName.indexOf('@') + 1) + } + + obtainRelationship() + repoResult.value = statusesRepository.getStatuses(accountId, statusId, disposables) + } + + fun navigateTo(screen: Screen) { + navigationMutable.value = screen + } + + fun navigated() { + navigationMutable.value = null + } + + + private fun obtainRelationship() { + val ids = listOf(accountId) + muteStateMutable.value = Loading() + blockStateMutable.value = Loading() + mastodonApi.relationships(ids) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { data -> + updateRelationship(data.getOrNull(0)) + + }, + { + updateRelationship(null) + } + ) + .autoDispose() + } + + + private fun updateRelationship(relationship: Relationship?) { + if (relationship != null) { + muteStateMutable.value = Success(relationship.muting) + blockStateMutable.value = Success(relationship.blocking) + } else { + muteStateMutable.value = Error(false) + blockStateMutable.value = Error(false) + } + } + + fun toggleMute() { + val alreadyMuted = muteStateMutable.value?.data == true + if (alreadyMuted) { + mastodonApi.unmuteAccount(accountId) + } else { + mastodonApi.muteAccount(accountId) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { relationship -> + val muting = relationship?.muting == true + muteStateMutable.value = Success(muting) + if (muting) { + eventHub.dispatch(MuteEvent(accountId, true)) + } + }, + { error -> + muteStateMutable.value = Error(false, error.message) + } + ).autoDispose() + + muteStateMutable.value = Loading() + } + + fun toggleBlock() { + val alreadyBlocked = blockStateMutable.value?.data == true + if (alreadyBlocked) { + mastodonApi.unblockAccount(accountId) + } else { + mastodonApi.blockAccount(accountId) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { relationship -> + val blocking = relationship?.blocking == true + blockStateMutable.value = Success(blocking) + if (blocking) { + eventHub.dispatch(BlockEvent(accountId)) + } + }, + { error -> + blockStateMutable.value = Error(false, error.message) + } + ) + .autoDispose() + + blockStateMutable.value = Loading() + } + + fun doReport() { + reportingStateMutable.value = Loading() + mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + reportingStateMutable.value = Success(true) + }, + { error -> + reportingStateMutable.value = Error(cause = error) + } + ) + .autoDispose() + + } + + fun retryStatusLoad() { + repoResult.value?.retry?.invoke() + } + + fun refreshStatuses() { + repoResult.value?.refresh?.invoke() + } + + fun checkClickedUrl(url: String?) { + checkUrlMutable.value = url + } + + fun urlChecked() { + checkUrlMutable.value = null + } + + fun setStatusChecked(status: Status, checked: Boolean) { + if (checked) { + selectedIds.add(status.id) + } else { + selectedIds.remove(status.id) + } + } + + fun isStatusChecked(id: String): Boolean { + return selectedIds.contains(id) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt new file mode 100644 index 0000000..643c46c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt @@ -0,0 +1,24 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report + +enum class Screen { + Statuses, + Note, + Done, + Back, + Finish +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt new file mode 100644 index 0000000..588cfef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt @@ -0,0 +1,28 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import android.view.View +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.LinkListener +import java.util.ArrayList + +interface AdapterHandler: LinkListener { + fun showMedia(v: View?, status: Status?, idx: Int) + fun setStatusChecked(status: Status, isChecked: Boolean) + fun isStatusChecked(id: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt new file mode 100644 index 0000000..506d99a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt @@ -0,0 +1,36 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment +import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment +import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment + +class ReportPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> ReportStatusesFragment.newInstance() + 1 -> ReportNoteFragment.newInstance() + 2 -> ReportDoneFragment.newInstance() + else -> throw IllegalArgumentException("Unknown page index: $position") + } + } + + override fun getItemCount() = 3 +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt new file mode 100644 index 0000000..93b3a7d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -0,0 +1,178 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import android.text.Spanned +import android.text.TextUtils +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.model.StatusViewState +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER +import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER +import com.keylesspalace.tusky.viewdata.toViewData +import kotlinx.android.synthetic.main.item_report_status.view.* +import java.util.* + +class StatusViewHolder( + itemView: View, + private val statusDisplayOptions: StatusDisplayOptions, + private val viewState: StatusViewState, + private val adapterHandler: AdapterHandler, + private val getStatusForPosition: (Int) -> Status? +) : RecyclerView.ViewHolder(itemView) { + private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) + private val statusViewHelper = StatusViewHelper(itemView) + + private val previewListener = object : StatusViewHelper.MediaPreviewListener { + override fun onViewMedia(v: View?, idx: Int) { + status()?.let { status -> + adapterHandler.showMedia(v, status, idx) + } + } + + override fun onContentHiddenChange(isShowing: Boolean) { + status()?.id?.let { id -> + viewState.setMediaShow(id, isShowing) + } + } + } + + init { + itemView.statusSelection.setOnCheckedChangeListener { _, isChecked -> + status()?.let { status -> + adapterHandler.setStatusChecked(status, isChecked) + } + } + itemView.status_media_preview_container.clipToOutline = true + } + + fun bind(status: Status) { + itemView.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) + + updateTextView() + + val sensitive = status.sensitive + + statusViewHelper.setMediasPreview(statusDisplayOptions, status.attachments, + sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), + mediaViewHeight) + + statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions.useAbsoluteTime) + setCreatedAt(status.createdAt) + } + + private fun updateTextView() { + status()?.let { status -> + setupCollapsedState(shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), + viewState.isContentShow(status.id, status.sensitive), status.spoilerText) + + if (status.spoilerText.isBlank()) { + setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler) + itemView.statusContentWarningButton.hide() + itemView.statusContentWarningDescription.hide() + } else { + val emojiSpoiler = status.spoilerText.emojify(status.emojis, itemView.statusContentWarningDescription) + itemView.statusContentWarningDescription.text = emojiSpoiler + itemView.statusContentWarningDescription.show() + itemView.statusContentWarningButton.show() + setContentWarningButtonText(viewState.isContentShow(status.id, true)) + itemView.statusContentWarningButton.setOnClickListener { + status()?.let { status -> + val contentShown = viewState.isContentShow(status.id, true) + itemView.statusContentWarningDescription.invalidate() + viewState.setContentShow(status.id, !contentShown) + setTextVisible(!contentShown, status.content, status.mentions, status.emojis, adapterHandler) + setContentWarningButtonText(!contentShown) + } + } + setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler) + } + } + } + + private fun setContentWarningButtonText(contentShown: Boolean) { + if(contentShown) { + itemView.statusContentWarningButton.setText(R.string.status_content_warning_show_less) + } else { + itemView.statusContentWarningButton.setText(R.string.status_content_warning_show_more) + } + } + + private fun setTextVisible(expanded: Boolean, + content: Spanned, + mentions: Array?, + emojis: List, + listener: LinkListener) { + if (expanded) { + val emojifiedText = content.emojify(emojis, itemView.statusContent) + LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener) + } else { + LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener) + } + if (itemView.statusContent.text.isNullOrBlank()) { + itemView.statusContent.hide() + } else { + itemView.statusContent.show() + } + } + + private fun setCreatedAt(createdAt: Date?) { + if (statusDisplayOptions.useAbsoluteTime) { + itemView.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) + } else { + itemView.timestampInfo.text = if (createdAt != null) { + val then = createdAt.time + val now = System.currentTimeMillis() + TimestampUtils.getRelativeTimeSpanString(itemView.timestampInfo.context, then, now) + } else { + // unknown minutes~ + "?m" + } + } + } + + + private fun setupCollapsedState(collapsible: Boolean, collapsed: Boolean, expanded: Boolean, spoilerText: String) { + /* input filter for TextViews have to be set before text */ + if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { + itemView.buttonToggleContent.setOnClickListener{ + status()?.let { status -> + viewState.setCollapsed(status.id, !collapsed) + updateTextView() + } + } + + itemView.buttonToggleContent.show() + if (collapsed) { + itemView.buttonToggleContent.setText(R.string.status_content_show_more) + itemView.statusContent.filters = COLLAPSE_INPUT_FILTER + } else { + itemView.buttonToggleContent.setText(R.string.status_content_show_less) + itemView.statusContent.filters = NO_INPUT_FILTER + } + } else { + itemView.buttonToggleContent.hide() + itemView.statusContent.filters = NO_INPUT_FILTER + } + } + + private fun status() = getStatusForPosition(adapterPosition) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt new file mode 100644 index 0000000..34817ca --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -0,0 +1,65 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.model.StatusViewState +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.StatusDisplayOptions + +class StatusesAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val statusViewState: StatusViewState, + private val adapterHandler: AdapterHandler +) : PagedListAdapter(STATUS_COMPARATOR) { + + private val statusForPosition: (Int) -> Status? = { position: Int -> + if (position != RecyclerView.NO_POSITION) getItem(position) else null + } + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_report_status, parent, false) + return StatusViewHolder(view, statusDisplayOptions, statusViewState, adapterHandler, + statusForPosition) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { status -> + (holder as? StatusViewHolder)?.bind(status) + } + + } + + companion object { + + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = + oldItem == newItem + + override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = + oldItem.id == newItem.id + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt new file mode 100644 index 0000000..12fd8cc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt @@ -0,0 +1,150 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import android.annotation.SuppressLint +import androidx.lifecycle.MutableLiveData +import androidx.paging.ItemKeyedDataSource +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.NetworkState +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.BiFunction +import java.util.concurrent.Executor + +class StatusesDataSource(private val accountId: String, + private val mastodonApi: MastodonApi, + private val disposables: CompositeDisposable, + private val retryExecutor: Executor) : ItemKeyedDataSource() { + + val networkStateAfter = MutableLiveData() + val networkStateBefore = MutableLiveData() + + private var retryAfter: (() -> Any)? = null + private var retryBefore: (() -> Any)? = null + private var retryInitial: (() -> Any)? = null + + val initialLoad = MutableLiveData() + fun retryAllFailed() { + var prevRetry = retryInitial + retryInitial = null + prevRetry?.let { + retryExecutor.execute { + it.invoke() + } + } + + prevRetry = retryAfter + retryAfter = null + prevRetry?.let { + retryExecutor.execute { + it.invoke() + } + } + + prevRetry = retryBefore + retryBefore = null + prevRetry?.let { + retryExecutor.execute { + it.invoke() + } + } + } + + @SuppressLint("CheckResult") + override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { + networkStateAfter.postValue(NetworkState.LOADED) + networkStateBefore.postValue(NetworkState.LOADED) + retryAfter = null + retryBefore = null + retryInitial = null + initialLoad.postValue(NetworkState.LOADING) + val initialKey = params.requestedInitialKey + if (initialKey == null) { + mastodonApi.accountStatusesObservable(accountId, null, null, params.requestedLoadSize, true) + } else { + mastodonApi.statusObservable(initialKey).zipWith( + mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true), + BiFunction { status: Status, list: List -> + val ret = ArrayList() + ret.add(status) + ret.addAll(list) + return@BiFunction ret + }) + } + .doOnSubscribe { + disposables.add(it) + } + .subscribe( + { + callback.onResult(it) + initialLoad.postValue(NetworkState.LOADED) + }, + { + retryInitial = { + loadInitial(params, callback) + } + initialLoad.postValue(NetworkState.error(it.message)) + } + ) + } + + @SuppressLint("CheckResult") + override fun loadAfter(params: LoadParams, callback: LoadCallback) { + networkStateAfter.postValue(NetworkState.LOADING) + retryAfter = null + mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true) + .doOnSubscribe { + disposables.add(it) + } + .subscribe( + { + callback.onResult(it) + networkStateAfter.postValue(NetworkState.LOADED) + }, + { + retryAfter = { + loadAfter(params, callback) + } + networkStateAfter.postValue(NetworkState.error(it.message)) + } + ) + } + + @SuppressLint("CheckResult") + override fun loadBefore(params: LoadParams, callback: LoadCallback) { + networkStateBefore.postValue(NetworkState.LOADING) + retryBefore = null + mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true) + .doOnSubscribe { + disposables.add(it) + } + .subscribe( + { + callback.onResult(it) + networkStateBefore.postValue(NetworkState.LOADED) + }, + { + retryBefore = { + loadBefore(params, callback) + } + networkStateBefore.postValue(NetworkState.error(it.message)) + } + ) + } + + override fun getKey(item: Status): String = item.id +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt new file mode 100644 index 0000000..4cf8ff1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt @@ -0,0 +1,36 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import androidx.lifecycle.MutableLiveData +import androidx.paging.DataSource +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import io.reactivex.disposables.CompositeDisposable +import java.util.concurrent.Executor + +class StatusesDataSourceFactory( + private val accountId: String, + private val mastodonApi: MastodonApi, + private val disposables: CompositeDisposable, + private val retryExecutor: Executor) : DataSource.Factory() { + val sourceLiveData = MutableLiveData() + override fun create(): DataSource { + val source = StatusesDataSource(accountId, mastodonApi, disposables, retryExecutor) + sourceLiveData.postValue(source) + return source + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt new file mode 100644 index 0000000..852a07f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt @@ -0,0 +1,61 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.adapter + +import androidx.lifecycle.Transformations +import androidx.paging.Config +import androidx.paging.toLiveData +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.BiListing +import com.keylesspalace.tusky.util.Listing +import io.reactivex.disposables.CompositeDisposable +import java.util.concurrent.Executors +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatusesRepository @Inject constructor(private val mastodonApi: MastodonApi) { + + private val executor = Executors.newSingleThreadExecutor() + + fun getStatuses(accountId: String, initialStatus: String?, disposables: CompositeDisposable, pageSize: Int = 20): BiListing { + val sourceFactory = StatusesDataSourceFactory(accountId, mastodonApi, disposables, executor) + val livePagedList = sourceFactory.toLiveData( + config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2), + fetchExecutor = executor, initialLoadKey = initialStatus + ) + return BiListing( + pagedList = livePagedList, + networkStateBefore = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.networkStateBefore + }, + networkStateAfter = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.networkStateAfter + }, + retry = { + sourceFactory.sourceLiveData.value?.retryAllFailed() + }, + refresh = { + sourceFactory.sourceLiveData.value?.invalidate() + }, + refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.initialLoad + } + + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt new file mode 100644 index 0000000..410f9dd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt @@ -0,0 +1,96 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.fragments + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.fragment.app.activityViewModels +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.report.Screen +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.android.synthetic.main.fragment_report_done.* +import javax.inject.Inject + +class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ReportViewModel by activityViewModels { viewModelFactory } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + textReported.text = getString(R.string.report_sent_success, viewModel.accountUserName) + handleClicks() + subscribeObservables() + } + + private fun subscribeObservables() { + viewModel.muteState.observe(viewLifecycleOwner, Observer { + if (it !is Loading) { + buttonMute.show() + progressMute.show() + } else { + buttonMute.hide() + progressMute.hide() + } + + buttonMute.setText(when (it.data) { + true -> R.string.action_unmute + else -> R.string.action_mute + }) + }) + + viewModel.blockState.observe(viewLifecycleOwner, Observer { + if (it !is Loading) { + buttonBlock.show() + progressBlock.show() + } + else{ + buttonBlock.hide() + progressBlock.hide() + } + buttonBlock.setText(when (it.data) { + true -> R.string.action_unblock + else -> R.string.action_block + }) + }) + + } + + private fun handleClicks() { + buttonDone.setOnClickListener { + viewModel.navigateTo(Screen.Finish) + } + buttonBlock.setOnClickListener { + viewModel.toggleBlock() + } + buttonMute.setOnClickListener { + viewModel.toggleMute() + } + } + + companion object { + fun newInstance() = ReportDoneFragment() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt new file mode 100644 index 0000000..e13fc81 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -0,0 +1,128 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.fragments + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.fragment.app.activityViewModels +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.report.Screen +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.* +import kotlinx.android.synthetic.main.fragment_report_note.* +import java.io.IOException +import javax.inject.Inject + +class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ReportViewModel by activityViewModels { viewModelFactory } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + fillViews() + handleChanges() + handleClicks() + subscribeObservables() + } + + private fun handleChanges() { + editNote.doAfterTextChanged { + viewModel.reportNote = it?.toString() ?: "" + } + checkIsNotifyRemote.setOnCheckedChangeListener { _, isChecked -> + viewModel.isRemoteNotify = isChecked + } + } + + private fun fillViews() { + editNote.setText(viewModel.reportNote) + + if (viewModel.isRemoteAccount){ + checkIsNotifyRemote.show() + reportDescriptionRemoteInstance.show() + } + else{ + checkIsNotifyRemote.hide() + reportDescriptionRemoteInstance.hide() + } + + if (viewModel.isRemoteAccount) + checkIsNotifyRemote.text = getString(R.string.report_remote_instance, viewModel.remoteServer) + checkIsNotifyRemote.isChecked = viewModel.isRemoteNotify + } + + private fun subscribeObservables() { + viewModel.reportingState.observe(viewLifecycleOwner, Observer { + when (it) { + is Success -> viewModel.navigateTo(Screen.Done) + is Loading -> showLoading() + is Error -> showError(it.cause) + + } + }) + } + + private fun showError(error: Throwable?) { + editNote.isEnabled = true + checkIsNotifyRemote.isEnabled = true + buttonReport.isEnabled = true + buttonBack.isEnabled = true + progressBar.hide() + + Snackbar.make(buttonBack, if (error is IOException) R.string.error_network else R.string.error_generic, Snackbar.LENGTH_LONG) + .apply { + setAction(R.string.action_retry) { + sendReport() + } + } + .show() + } + + private fun sendReport() { + viewModel.doReport() + } + + private fun showLoading() { + buttonReport.isEnabled = false + buttonBack.isEnabled = false + editNote.isEnabled = false + checkIsNotifyRemote.isEnabled = false + progressBar.show() + } + + private fun handleClicks() { + buttonBack.setOnClickListener { + viewModel.navigateTo(Screen.Back) + } + + buttonReport.setOnClickListener { + sendReport() + } + } + + companion object { + fun newInstance() = ReportNoteFragment() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt new file mode 100644 index 0000000..ad4b6ce --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -0,0 +1,200 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.fragments + +import android.os.Bundle +import android.view.View +import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.paging.PagedList +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.report.Screen +import com.keylesspalace.tusky.components.report.adapter.AdapterHandler +import com.keylesspalace.tusky.components.report.adapter.StatusesAdapter +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import kotlinx.android.synthetic.main.fragment_report_statuses.* +import javax.inject.Inject + +class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Injectable, AdapterHandler { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var accountManager: AccountManager + + private val viewModel: ReportViewModel by activityViewModels { viewModelFactory } + + private lateinit var adapter: StatusesAdapter + + private var snackbarErrorRetry: Snackbar? = null + + override fun showMedia(v: View?, status: Status?, idx: Int) { + status?.actionableStatus?.let { actionable -> + when (actionable.attachments[idx].type) { + Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { + val attachments = AttachmentViewData.list(actionable) + val intent = ViewMediaActivity.newIntent(context, attachments, + idx) + if (v != null) { + val url = actionable.attachments[idx].url + ViewCompat.setTransitionName(v, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), + v, url) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Attachment.Type.UNKNOWN -> { + } + } + + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + handleClicks() + initStatusesView() + setupSwipeRefreshLayout() + } + + private fun setupSwipeRefreshLayout() { + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + swipeRefreshLayout.setOnRefreshListener { + snackbarErrorRetry?.dismiss() + viewModel.refreshStatuses() + } + } + + private fun initStatusesView() { + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = false, + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = false, + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + renderStatusAsMention = preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + ) + + adapter = StatusesAdapter(statusDisplayOptions, + viewModel.statusViewState, this) + + recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.adapter = adapter + (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + viewModel.statuses.observe(viewLifecycleOwner, Observer> { + adapter.submitList(it) + }) + + viewModel.networkStateAfter.observe(viewLifecycleOwner, Observer { + if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING) + progressBarBottom.show() + else + progressBarBottom.hide() + + if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) + showError(it.msg) + }) + + viewModel.networkStateBefore.observe(viewLifecycleOwner, Observer { + if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING) + progressBarTop.show() + else + progressBarTop.hide() + + if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) + showError(it.msg) + }) + + viewModel.networkStateRefresh.observe(viewLifecycleOwner, Observer { + if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING && !swipeRefreshLayout.isRefreshing) + progressBarLoading.show() + else + progressBarLoading.hide() + + if (it?.status != com.keylesspalace.tusky.util.Status.RUNNING) + swipeRefreshLayout.isRefreshing = false + if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) + showError(it.msg) + }) + } + + private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) { + if (snackbarErrorRetry?.isShown != true) { + snackbarErrorRetry = Snackbar.make(swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE) + snackbarErrorRetry?.setAction(R.string.action_retry) { + viewModel.retryStatusLoad() + } + snackbarErrorRetry?.show() + } + } + + + private fun handleClicks() { + buttonCancel.setOnClickListener { + viewModel.navigateTo(Screen.Back) + } + + buttonContinue.setOnClickListener { + viewModel.navigateTo(Screen.Note) + } + } + + override fun setStatusChecked(status: Status, isChecked: Boolean) { + viewModel.setStatusChecked(status, isChecked) + } + + override fun isStatusChecked(id: String): Boolean { + return viewModel.isStatusChecked(id) + } + + override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) + + override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag)) + + override fun onViewUrl(url: String?) = viewModel.checkClickedUrl(url) + + companion object { + fun newInstance() = ReportStatusesFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt new file mode 100644 index 0000000..664ddc6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt @@ -0,0 +1,36 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.report.model + +class StatusViewState { + private val mediaShownState = HashMap() + private val contentShownState = HashMap() + private val longContentCollapsedState = HashMap() + + fun isMediaShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(mediaShownState, id, !isSensitive) + fun setMediaShow(id: String, isShow: Boolean) = setStateEnabled(mediaShownState, id, isShow) + + fun isContentShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(contentShownState, id, !isSensitive) + fun setContentShow(id: String, isShow: Boolean) = setStateEnabled(contentShownState, id, isShow) + + fun isCollapsed(id: String, isCollapsed: Boolean): Boolean = isStateEnabled(longContentCollapsedState, id, isCollapsed) + fun setCollapsed(id: String, isCollapsed: Boolean) = setStateEnabled(longContentCollapsedState, id, isCollapsed) + + private fun isStateEnabled(map: Map, id: String, def: Boolean): Boolean = map[id] + ?: def + + private fun setStateEnabled(map: MutableMap, id: String, state: Boolean) = map.put(id, state) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt new file mode 100644 index 0000000..29f16fa --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt @@ -0,0 +1,149 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.scheduled + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.util.Status +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.android.synthetic.main.activity_scheduled_toot.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + lateinit var viewModel: ScheduledTootViewModel + + private val adapter = ScheduledTootAdapter(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_scheduled_toot) + + setSupportActionBar(toolbar) + supportActionBar?.run { + title = getString(R.string.title_scheduled_toot) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + swipeRefreshLayout.setOnRefreshListener(this::refreshStatuses) + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + scheduledTootList.setHasFixedSize(true) + scheduledTootList.layoutManager = LinearLayoutManager(this) + val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + scheduledTootList.addItemDecoration(divider) + scheduledTootList.adapter = adapter + + viewModel = ViewModelProvider(this, viewModelFactory)[ScheduledTootViewModel::class.java] + + viewModel.data.observe(this, Observer { + adapter.submitList(it) + }) + + viewModel.networkState.observe(this, Observer { (status) -> + when(status) { + Status.SUCCESS -> { + progressBar.hide() + swipeRefreshLayout.isRefreshing = false + if(viewModel.data.value?.loadedCount == 0) { + errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_status) + errorMessageView.show() + } else { + errorMessageView.hide() + } + } + Status.RUNNING -> { + errorMessageView.hide() + if(viewModel.data.value?.loadedCount ?: 0 > 0) { + swipeRefreshLayout.isRefreshing = true + } else { + progressBar.show() + } + } + Status.FAILED -> { + if(viewModel.data.value?.loadedCount ?: 0 >= 0) { + progressBar.hide() + swipeRefreshLayout.isRefreshing = false + errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { + refreshStatuses() + } + errorMessageView.show() + } + } + } + + }) + + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun refreshStatuses() { + viewModel.reload() + } + + override fun edit(item: ScheduledStatus) { + val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( + scheduledTootId = item.id, + tootText = item.params.text, + contentWarning = item.params.spoilerText, + mediaAttachments = item.mediaAttachments, + inReplyToId = item.params.inReplyToId, + visibility = item.params.visibility, + scheduledAt = item.scheduledAt, + sensitive = item.params.sensitive + )) + startActivity(intent) + } + + override fun delete(item: ScheduledStatus) { + viewModel.deleteScheduledStatus(item) + } + + companion object { + @JvmStatic + fun newIntent(context: Context): Intent { + return Intent(context, ScheduledTootActivity::class.java) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt new file mode 100644 index 0000000..ea12d1f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt @@ -0,0 +1,85 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.scheduled + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.ScheduledStatus + +interface ScheduledTootActionListener { + fun edit(item: ScheduledStatus) + fun delete(item: ScheduledStatus) +} + +class ScheduledTootAdapter( + val listener: ScheduledTootActionListener +) : PagedListAdapter( + object: DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { + return oldItem == newItem + } + + } +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TootViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_scheduled_toot, parent, false) + return TootViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: TootViewHolder, position: Int) { + getItem(position)?.let{ + viewHolder.bind(it) + } + } + + + inner class TootViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + private val text: TextView = view.findViewById(R.id.text) + private val edit: ImageButton = view.findViewById(R.id.edit) + private val delete: ImageButton = view.findViewById(R.id.delete) + + fun bind(item: ScheduledStatus) { + edit.isEnabled = true + delete.isEnabled = true + text.text = item.params.text + edit.setOnClickListener { v: View -> + v.isEnabled = false + listener.edit(item) + } + delete.setOnClickListener { v: View -> + v.isEnabled = false + listener.delete(item) + } + + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootDataSource.kt new file mode 100644 index 0000000..6c9ba31 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootDataSource.kt @@ -0,0 +1,102 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.scheduled + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.paging.DataSource +import androidx.paging.ItemKeyedDataSource +import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.NetworkState +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo + +class ScheduledTootDataSourceFactory( + private val mastodonApi: MastodonApi, + private val disposables: CompositeDisposable +): DataSource.Factory() { + + private val scheduledTootsCache = mutableListOf() + + private var dataSource: ScheduledTootDataSource? = null + + val networkState = MutableLiveData() + + override fun create(): DataSource { + return ScheduledTootDataSource(mastodonApi, disposables, scheduledTootsCache, networkState).also { + dataSource = it + } + } + + fun reload() { + scheduledTootsCache.clear() + dataSource?.invalidate() + } + + fun remove(status: ScheduledStatus) { + scheduledTootsCache.remove(status) + dataSource?.invalidate() + } + +} + + +class ScheduledTootDataSource( + private val mastodonApi: MastodonApi, + private val disposables: CompositeDisposable, + private val scheduledTootsCache: MutableList, + private val networkState: MutableLiveData +): ItemKeyedDataSource() { + override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { + if(scheduledTootsCache.isNotEmpty()) { + callback.onResult(scheduledTootsCache.toList()) + } else { + networkState.postValue(NetworkState.LOADING) + mastodonApi.scheduledStatuses(limit = params.requestedLoadSize) + .subscribe({ newData -> + scheduledTootsCache.addAll(newData) + callback.onResult(newData) + networkState.postValue(NetworkState.LOADED) + }, { throwable -> + Log.w("ScheduledTootDataSource", "Error loading scheduled statuses", throwable) + networkState.postValue(NetworkState.error(throwable.message)) + }) + .addTo(disposables) + } + } + + override fun loadAfter(params: LoadParams, callback: LoadCallback) { + mastodonApi.scheduledStatuses(limit = params.requestedLoadSize, maxId = params.key) + .subscribe({ newData -> + scheduledTootsCache.addAll(newData) + callback.onResult(newData) + }, { throwable -> + Log.w("ScheduledTootDataSource", "Error loading scheduled statuses", throwable) + networkState.postValue(NetworkState.error(throwable.message)) + }) + .addTo(disposables) + } + + override fun loadBefore(params: LoadParams, callback: LoadCallback) { + // we are always loading from beginning to end + } + + override fun getKey(item: ScheduledStatus): String { + return item.id + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt new file mode 100644 index 0000000..3584168 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt @@ -0,0 +1,68 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.scheduled + +import android.util.Log +import androidx.paging.Config +import androidx.paging.toLiveData +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.RxAwareViewModel +import io.reactivex.android.schedulers.AndroidSchedulers +import javax.inject.Inject + +class ScheduledTootViewModel @Inject constructor( + val mastodonApi: MastodonApi, + val eventHub: EventHub +): RxAwareViewModel() { + + private val dataSourceFactory = ScheduledTootDataSourceFactory(mastodonApi, disposables) + + val data = dataSourceFactory.toLiveData( + config = Config(pageSize = 20, initialLoadSizeHint = 20, enablePlaceholders = false) + ) + + val networkState = dataSourceFactory.networkState + + init { + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { event -> + if (event is StatusScheduledEvent) { + reload() + } + } + .autoDispose() + } + + fun reload() { + dataSourceFactory.reload() + } + + fun deleteScheduledStatus(status: ScheduledStatus) { + mastodonApi.deleteScheduledStatus(status.id) + .subscribe({ + dataSourceFactory.remove(status) + },{ throwable -> + Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) + }) + .autoDispose() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt new file mode 100644 index 0000000..8b8d1ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -0,0 +1,126 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search + +import android.app.SearchManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import com.google.android.material.tabs.TabLayoutMediator +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter +import com.keylesspalace.tusky.di.ViewModelFactory +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import kotlinx.android.synthetic.main.activity_search.* +import javax.inject.Inject + +class SearchActivity : BottomSheetActivity(), HasAndroidInjector { + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: SearchViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_search) + setSupportActionBar(toolbar) + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setDisplayShowTitleEnabled(false) + } + setupPages() + handleIntent(intent) + } + + private fun setupPages() { + pages.adapter = SearchPagerAdapter(this) + + TabLayoutMediator(tabs, pages) { + tab, position -> + tab.text = getPageTitle(position) + }.attach() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + super.onCreateOptionsMenu(menu) + + menuInflater.inflate(R.menu.search_toolbar, menu) + val searchView = menu.findItem(R.id.action_search) + .actionView as SearchView + setupSearchView(searchView) + + searchView.setQuery(viewModel.currentQuery, false) + + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun getPageTitle(position: Int): CharSequence? { + return when (position) { + 0 -> getString(R.string.title_statuses) + 1 -> getString(R.string.title_accounts) + 2 -> getString(R.string.title_hashtags_dialog) + else -> throw IllegalArgumentException("Unknown page index: $position") + } + } + + private fun handleIntent(intent: Intent) { + if (Intent.ACTION_SEARCH == intent.action) { + viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY) ?: "" + viewModel.search(viewModel.currentQuery) + } + } + + private fun setupSearchView(searchView: SearchView) { + searchView.setIconifiedByDefault(false) + + searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName)) + + searchView.requestFocus() + + searchView.maxWidth = Integer.MAX_VALUE + } + + override fun androidInjector() = androidInjector + + companion object { + fun getIntent(context: Context) = Intent(context, SearchActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt new file mode 100644 index 0000000..5df6574 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt @@ -0,0 +1,7 @@ +package com.keylesspalace.tusky.components.search + +enum class SearchType(val apiParameter: String) { + Status("statuses"), + Account("accounts"), + Hashtag("hashtags") +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt new file mode 100644 index 0000000..8394457 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -0,0 +1,242 @@ +package com.keylesspalace.tusky.components.search + +import android.util.Log +import android.view.View +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.paging.PagedList +import com.keylesspalace.tusky.components.search.adapter.SearchRepository +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewdata.StatusViewData +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import javax.inject.Inject + +class SearchViewModel @Inject constructor( + mastodonApi: MastodonApi, + private val timelineCases: TimelineCases, + private val accountManager: AccountManager +) : RxAwareViewModel() { + + var currentQuery: String = "" + + var activeAccount: AccountEntity? + get() = accountManager.activeAccount + set(value) { + accountManager.activeAccount = value + } + + val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false + val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false + val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false + + private val statusesRepository = SearchRepository>(mastodonApi) + private val accountsRepository = SearchRepository(mastodonApi) + private val hashtagsRepository = SearchRepository(mastodonApi) + + private val repoResultStatus = MutableLiveData>>() + val statuses: LiveData>> = repoResultStatus.switchMap { it.pagedList } + val networkStateStatus: LiveData = repoResultStatus.switchMap { it.networkState } + val networkStateStatusRefresh: LiveData = repoResultStatus.switchMap { it.refreshState } + + private val repoResultAccount = MutableLiveData>() + val accounts: LiveData> = repoResultAccount.switchMap { it.pagedList } + val networkStateAccount: LiveData = repoResultAccount.switchMap { it.networkState } + val networkStateAccountRefresh: LiveData = repoResultAccount.switchMap { it.refreshState } + + private val repoResultHashTag = MutableLiveData>() + val hashtags: LiveData> = repoResultHashTag.switchMap { it.pagedList } + val networkStateHashTag: LiveData = repoResultHashTag.switchMap { it.networkState } + val networkStateHashTagRefresh: LiveData = repoResultHashTag.switchMap { it.refreshState } + + private val loadedStatuses = ArrayList>() + fun search(query: String) { + loadedStatuses.clear() + repoResultStatus.value = statusesRepository.getSearchData(SearchType.Status, query, disposables, initialItems = loadedStatuses) { + it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) } + .orEmpty() + .apply { + loadedStatuses.addAll(this) + } + } + repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) { + it?.accounts.orEmpty() + } + val hashtagQuery = if (query.startsWith("#")) query else "#$query" + repoResultHashTag.value = + hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) { + it?.hashtags.orEmpty() + } + + } + + fun removeItem(status: Pair) { + timelineCases.delete(status.first.id) + .subscribe({ + if (loadedStatuses.remove(status)) + repoResultStatus.value?.refresh?.invoke() + }, { + err -> Log.d(TAG, "Failed to delete status", err) + }) + .autoDispose() + + } + + fun expandedChange(status: Pair, expanded: Boolean) { + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData()) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + } + + fun reblog(status: Pair, reblog: Boolean) { + timelineCases.reblog(status.first, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { setRebloggedForStatus(status, reblog) }, + { err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) } + ) + .autoDispose() + } + + private fun setRebloggedForStatus(status: Pair, reblog: Boolean) { + status.first.reblogged = reblog + status.first.reblog?.reblogged = reblog + + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData()) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + } + + fun contentHiddenChange(status: Pair, isShowing: Boolean) { + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData()) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + } + + fun collapsedChange(status: Pair, collapsed: Boolean) { + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData()) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + } + + fun voteInPoll(status: Pair, choices: MutableList) { + val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices) + updateStatus(status, votedPoll) + timelineCases.voteInPoll(status.first, choices) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { newPoll -> updateStatus(status, newPoll) }, + { t -> + Log.d(TAG, + "Failed to vote in poll: ${status.first.id}", t) + } + ) + .autoDispose() + } + + private fun updateStatus(status: Pair, newPoll: Poll) { + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + + val newViewData = StatusViewData.Builder(status.second) + .setPoll(newPoll) + .createStatusViewData() + loadedStatuses[idx] = Pair(status.first, newViewData) + repoResultStatus.value?.refresh?.invoke() + } + } + + fun favorite(status: Pair, isFavorited: Boolean) { + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData()) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + timelineCases.favourite(status.first, isFavorited) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() + } + + fun bookmark(status: Pair, isBookmarked: Boolean) { + val idx = loadedStatuses.indexOf(status) + if (idx >= 0) { + val newPair = Pair(status.first, StatusViewData.Builder(status.second).setBookmarked(isBookmarked).createStatusViewData()) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + timelineCases.bookmark(status.first, isBookmarked) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() + } + + fun getAllAccountsOrderedByActive(): List { + return accountManager.getAllAccountsOrderedByActive() + } + + fun muteAccount(accountId: String, notifications: Boolean, duration: Int) { + timelineCases.mute(accountId, notifications, duration) + } + + fun muteConversation(status: Status, isMute: Boolean) { + timelineCases.muteConversation(status, isMute) + } + + fun pinAccount(status: Status, isPin: Boolean) { + timelineCases.pin(status, isPin) + } + + fun blockAccount(accountId: String) { + timelineCases.block(accountId) + } + + fun deleteStatus(id: String): Single { + return timelineCases.delete(id) + } + + fun retryAllSearches() { + search(currentQuery) + } + + fun setEmojiReactionForStatus(idx: Int, newStatus: Status) { + val newPair = Pair(newStatus, + ViewDataUtils.statusToViewData(newStatus, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) + loadedStatuses[idx] = newPair + repoResultStatus.value?.refresh?.invoke() + } + + fun emojiReact(react: Boolean, emoji: String, statusId: String) { + loadedStatuses.indexOfFirst { it.first.id == statusId }.let { idx -> + timelineCases.react(emoji, statusId, react) + .subscribe( + { newStatus -> setEmojiReactionForStatus(idx, newStatus)}, + { Log.d(TAG,"Failed to react with $emoji to ${loadedStatuses[idx].first.id}", it)} + ) + .autoDispose() + } + } + + companion object { + private const val TAG = "SearchViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt new file mode 100644 index 0000000..c135ad7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt @@ -0,0 +1,58 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.AccountViewHolder +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.interfaces.LinkListener + +class SearchAccountsAdapter(private val linkListener: LinkListener) + : PagedListAdapter(ACCOUNT_COMPARATOR) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_account, parent, false) + return AccountViewHolder(view) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { item -> + (holder as AccountViewHolder).apply { + setupWithAccount(item) + setupLinkListener(linkListener) + } + } + } + + companion object { + + val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean = + oldItem.deepEquals(newItem) + + override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = + oldItem.id == newItem.id + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt new file mode 100644 index 0000000..2b70628 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt @@ -0,0 +1,126 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import androidx.lifecycle.MutableLiveData +import androidx.paging.PositionalDataSource +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.NetworkState +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import java.util.concurrent.Executor + +class SearchDataSource( + private val mastodonApi: MastodonApi, + private val searchType: SearchType, + private val searchRequest: String, + private val disposables: CompositeDisposable, + private val retryExecutor: Executor, + private val initialItems: List? = null, + private val parser: (SearchResult?) -> List, + private val source: SearchDataSourceFactory) : PositionalDataSource() { + + val networkState = MutableLiveData() + + private var retry: (() -> Any)? = null + + val initialLoad = MutableLiveData() + + fun retry() { + retry?.let { + retryExecutor.execute { + it.invoke() + } + } + } + + override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { + if (!initialItems.isNullOrEmpty()) { + callback.onResult(initialItems.toList(), 0) + } else { + networkState.postValue(NetworkState.LOADED) + retry = null + initialLoad.postValue(NetworkState.LOADING) + mastodonApi.searchObservable( + query = searchRequest, + type = searchType.apiParameter, + resolve = true, + limit = params.requestedLoadSize, + offset = 0, + following = false) + .subscribe( + { data -> + val res = parser(data) + callback.onResult(res, params.requestedStartPosition) + initialLoad.postValue(NetworkState.LOADED) + + }, + { error -> + retry = { + loadInitial(params, callback) + } + initialLoad.postValue(NetworkState.error(error.message)) + } + ).addTo(disposables) + } + + } + + override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { + networkState.postValue(NetworkState.LOADING) + retry = null + if (source.exhausted) { + return callback.onResult(emptyList()) + } + mastodonApi.searchObservable( + query = searchRequest, + type = searchType.apiParameter, + resolve = true, + limit = params.loadSize, + offset = params.startPosition, + following = false) + .subscribe( + { data -> + // Working around Mastodon bug where exact match is returned no matter + // which offset is requested (so if we search for a full username, it's + // infinite) + // see https://github.com/tootsuite/mastodon/issues/11365 + // see https://github.com/tootsuite/mastodon/issues/13083 + val res = if ((data.accounts.size == 1 && data.accounts[0].username.equals(searchRequest, ignoreCase = true)) + || (data.statuses.size == 1 && data.statuses[0].url.equals(searchRequest))) { + listOf() + } else { + parser(data) + } + if (res.isEmpty()) { + source.exhausted = true + } + callback.onResult(res) + networkState.postValue(NetworkState.LOADED) + }, + { error -> + retry = { + loadRange(params, callback) + } + networkState.postValue(NetworkState.error(error.message)) + } + ).addTo(disposables) + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt new file mode 100644 index 0000000..b47da70 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt @@ -0,0 +1,44 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import androidx.lifecycle.MutableLiveData +import androidx.paging.DataSource +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.network.MastodonApi +import io.reactivex.disposables.CompositeDisposable +import java.util.concurrent.Executor + +class SearchDataSourceFactory( + private val mastodonApi: MastodonApi, + private val searchType: SearchType, + private val searchRequest: String, + private val disposables: CompositeDisposable, + private val retryExecutor: Executor, + private val cacheData: List? = null, + private val parser: (SearchResult?) -> List) : DataSource.Factory() { + + val sourceLiveData = MutableLiveData>() + + var exhausted = false + + override fun create(): DataSource { + val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this) + sourceLiveData.postValue(source) + return source + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt new file mode 100644 index 0000000..71863d4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt @@ -0,0 +1,55 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.HashtagViewHolder +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.interfaces.LinkListener + +class SearchHashtagsAdapter(private val linkListener: LinkListener) + : PagedListAdapter(HASHTAG_COMPARATOR) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_hashtag, parent, false) + return HashtagViewHolder(view) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { (name) -> + (holder as HashtagViewHolder).setup(name, linkListener) + } + } + + companion object { + + val HASHTAG_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = + oldItem.name == newItem.name + + override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = + oldItem.name == newItem.name + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt new file mode 100644 index 0000000..845abaf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt @@ -0,0 +1,38 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment +import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment +import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment + +class SearchPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> SearchStatusesFragment.newInstance() + 1 -> SearchAccountsFragment.newInstance() + 2 -> SearchHashtagsFragment.newInstance() + else -> throw IllegalArgumentException("Unknown page index: $position") + } + } + + override fun getItemCount() = 3 + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt new file mode 100644 index 0000000..28d9564 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt @@ -0,0 +1,56 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import androidx.lifecycle.Transformations +import androidx.paging.Config +import androidx.paging.toLiveData +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Listing +import io.reactivex.disposables.CompositeDisposable +import java.util.concurrent.Executors + +class SearchRepository(private val mastodonApi: MastodonApi) { + + private val executor = Executors.newSingleThreadExecutor() + + fun getSearchData(searchType: SearchType, searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20, + initialItems: List? = null, parser: (SearchResult?) -> List): Listing { + val sourceFactory = SearchDataSourceFactory(mastodonApi, searchType, searchRequest, disposables, executor, initialItems, parser) + val livePagedList = sourceFactory.toLiveData( + config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2), + fetchExecutor = executor + ) + return Listing( + pagedList = livePagedList, + networkState = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.networkState + }, + retry = { + sourceFactory.sourceLiveData.value?.retry() + }, + refresh = { + sourceFactory.sourceLiveData.value?.invalidate() + }, + refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.initialLoad + } + + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt new file mode 100644 index 0000000..0fcee37 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -0,0 +1,63 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData + +class SearchStatusesAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener +) : PagedListAdapter, RecyclerView.ViewHolder>(STATUS_COMPARATOR) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status, parent, false) + return StatusViewHolder(view) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { item -> + (holder as StatusViewHolder).setupWithStatus(item.second, statusListener, statusDisplayOptions) + } + } + + public override fun getItem(position: Int): Pair? { + return super.getItem(position) + } + + companion object { + + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback>() { + override fun areContentsTheSame(oldItem: Pair, newItem: Pair): Boolean = + oldItem.second.deepEquals(newItem.second) + + override fun areItemsTheSame(oldItem: Pair, newItem: Pair): Boolean = + oldItem.second.id == newItem.second.id + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt new file mode 100644 index 0000000..714580f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -0,0 +1,39 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.fragments + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import androidx.paging.PagedListAdapter +import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.util.NetworkState + +class SearchAccountsFragment : SearchFragment() { + override fun createAdapter(): PagedListAdapter = SearchAccountsAdapter(this) + + override val networkStateRefresh: LiveData + get() = viewModel.networkStateAccountRefresh + override val networkState: LiveData + get() = viewModel.networkStateAccount + override val data: LiveData> + get() = viewModel.accounts + + companion object { + fun newInstance() = SearchAccountsFragment() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt new file mode 100644 index 0000000..faae08e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -0,0 +1,132 @@ +package com.keylesspalace.tusky.components.search.fragments + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.paging.PagedList +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.components.search.SearchViewModel +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.* +import kotlinx.android.synthetic.main.fragment_search.* +import javax.inject.Inject + +abstract class SearchFragment : Fragment(R.layout.fragment_search), + LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory } + + private var snackbarErrorRetry: Snackbar? = null + + abstract fun createAdapter(): PagedListAdapter + + abstract val networkStateRefresh: LiveData + abstract val networkState: LiveData + abstract val data: LiveData> + protected lateinit var adapter: PagedListAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initAdapter() + setupSwipeRefreshLayout() + subscribeObservables() + } + + private fun setupSwipeRefreshLayout() { + swipeRefreshLayout.setOnRefreshListener(this) + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun subscribeObservables() { + data.observe(viewLifecycleOwner, Observer { + adapter.submitList(it) + }) + + networkStateRefresh.observe(viewLifecycleOwner, Observer { + + searchProgressBar.visible(it == NetworkState.LOADING) + + if (it.status == Status.FAILED) { + showError() + } + checkNoData() + + }) + + networkState.observe(viewLifecycleOwner, Observer { + + progressBarBottom.visible(it == NetworkState.LOADING) + + if (it.status == Status.FAILED) { + showError() + } + }) + } + + private fun checkNoData() { + showNoData(adapter.itemCount == 0) + } + + private fun initAdapter() { + searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL)) + searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context) + adapter = createAdapter() + searchRecyclerView.adapter = adapter + searchRecyclerView.setHasFixedSize(true) + (searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + } + + private fun showNoData(isEmpty: Boolean) { + if (isEmpty && networkStateRefresh.value == NetworkState.LOADED) + searchNoResultsText.show() + else + searchNoResultsText.hide() + } + + private fun showError() { + if (snackbarErrorRetry?.isShown != true) { + snackbarErrorRetry = Snackbar.make(layoutRoot, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) + snackbarErrorRetry?.setAction(R.string.action_retry) { + snackbarErrorRetry = null + viewModel.retryAllSearches() + } + snackbarErrorRetry?.show() + } + } + + override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) + + override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag)) + + override fun onViewUrl(url: String) { + bottomSheetActivity?.viewUrl(url) + } + + protected val bottomSheetActivity + get() = (activity as? BottomSheetActivity) + + override fun onRefresh() { + + // Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins. + swipeRefreshLayout.post { + swipeRefreshLayout.isRefreshing = false + } + viewModel.retryAllSearches() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt new file mode 100644 index 0000000..15310d3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt @@ -0,0 +1,38 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.fragments + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import androidx.paging.PagedListAdapter +import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.util.NetworkState + +class SearchHashtagsFragment : SearchFragment() { + override val networkStateRefresh: LiveData + get() = viewModel.networkStateHashTagRefresh + override val networkState: LiveData + get() = viewModel.networkStateHashTag + override val data: LiveData> + get() = viewModel.hashtags + + override fun createAdapter(): PagedListAdapter = SearchHashtagsAdapter(this) + + companion object { + fun newInstance() = SearchHashtagsFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt new file mode 100644 index 0000000..8f6a449 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -0,0 +1,517 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.search.fragments + +import android.Manifest +import android.app.DownloadManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Environment +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.widget.CheckBox +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.PopupMenu +import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import androidx.paging.PagedListAdapter +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.components.report.ReportActivity +import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.EmojiReaction +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Status.Mention +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.view.showMuteAccountDialog +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.fragment_search.* + +class SearchStatusesFragment : SearchFragment>(), StatusActionListener { + + override val networkStateRefresh: LiveData + get() = viewModel.networkStateStatusRefresh + override val networkState: LiveData + get() = viewModel.networkStateStatus + override val data: LiveData>> + get() = viewModel.statuses + + private val searchAdapter + get() = super.adapter as SearchStatusesAdapter + + override fun createAdapter(): PagedListAdapter, *> { + val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = viewModel.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + renderStatusAsMention = preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + ) + + searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL)) + searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context) + return SearchStatusesAdapter(statusDisplayOptions, this) + } + + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + searchAdapter.getItem(position)?.let { + viewModel.contentHiddenChange(it, isShowing) + } + } + + override fun onReply(position: Int) { + searchAdapter.getItem(position)?.first?.let { status -> + reply(status) + } + } + + override fun onFavourite(favourite: Boolean, position: Int) { + searchAdapter.getItem(position)?.let { status -> + viewModel.favorite(status, favourite) + } + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + searchAdapter.getItem(position)?.let { status -> + viewModel.bookmark(status, bookmark) + } + } + + override fun onMore(view: View, position: Int) { + searchAdapter.getItem(position)?.first?.let { + more(it, view, position) + } + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + searchAdapter.getItem(position)?.first?.actionableStatus?.let { actionable -> + when (actionable.attachments[attachmentIndex].type) { + Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { + val attachments = AttachmentViewData.list(actionable) + val intent = ViewMediaActivity.newIntent(context, attachments, + attachmentIndex) + if (view != null) { + val url = actionable.attachments[attachmentIndex].url + ViewCompat.setTransitionName(view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), + view, url) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Attachment.Type.UNKNOWN -> { + LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context) + } + } + + } + + } + + override fun onViewThread(position: Int) { + searchAdapter.getItem(position)?.first?.let { status -> + val actionableStatus = status.actionableStatus + bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) + } + } + + override fun onViewReplyTo(position: Int) { + searchAdapter.getItem(position)?.first?.let { status -> + val actionableStatus = status.actionableStatus + bottomSheetActivity?.viewThread(actionableStatus.inReplyToId!!, null) + } + } + + override fun onOpenReblog(position: Int) { + searchAdapter.getItem(position)?.first?.let { status -> + bottomSheetActivity?.viewAccount(status.account.id) + } + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + searchAdapter.getItem(position)?.let { + viewModel.expandedChange(it, expanded) + } + } + + override fun onLoadMore(position: Int) { + // Not possible here + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + searchAdapter.getItem(position)?.let { + viewModel.collapsedChange(it, isCollapsed) + } + } + + override fun onVoteInPoll(position: Int, choices: MutableList) { + searchAdapter.getItem(position)?.let { + viewModel.voteInPoll(it, choices) + } + } + + private fun removeItem(position: Int) { + searchAdapter.getItem(position)?.let { + viewModel.removeItem(it) + } + } + + override fun onReblog(reblog: Boolean, position: Int) { + searchAdapter.getItem(position)?.let { status -> + viewModel.reblog(status, reblog) + } + } + + companion object { + fun newInstance() = SearchStatusesFragment() + } + + private fun reply(status: Status) { + val actionableStatus = status.actionableStatus + val mentionedUsernames = actionableStatus.mentions.map { it.username } + .toMutableSet() + .apply { + add(actionableStatus.account.username) + remove(viewModel.activeAccount?.username) + } + + val intent = ComposeActivity.startIntent(requireContext(), ComposeOptions( + inReplyToId = status.actionableId, + replyVisibility = actionableStatus.visibility, + contentWarning = actionableStatus.spoilerText, + mentionedUsernames = mentionedUsernames, + replyingStatusAuthor = actionableStatus.account.localUsername, + replyingStatusContent = actionableStatus.content.toString() + )) + startActivity(intent) + } + + private fun more(status: Status, view: View, position: Int) { + val id = status.actionableId + val accountId = status.actionableStatus.account.id + val accountUsername = status.actionableStatus.account.username + val statusUrl = status.actionableStatus.url + val accounts = viewModel.getAllAccountsOrderedByActive() + var openAsTitle: String? = null + + val loggedInAccountId = viewModel.activeAccount?.accountId + + val popup = PopupMenu(view.context, view) + // Give a different menu depending on whether this is the user's own toot or not. + if (loggedInAccountId == null || loggedInAccountId != accountId) { + popup.inflate(R.menu.status_more) + val menu = popup.menu + menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() + } else { + popup.inflate(R.menu.status_more_for_user) + val menu = popup.menu + menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank() + when (status.visibility) { + Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { + val textId = getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action) + menu.add(0, R.id.pin, 1, textId) + } + Status.Visibility.PRIVATE -> { + var reblogged = status.reblogged + if (status.reblog != null) reblogged = status.reblog.reblogged + menu.findItem(R.id.status_reblog_private).isVisible = !reblogged + menu.findItem(R.id.status_unreblog_private).isVisible = reblogged + } + Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> { + } //Ignore + } + } + + val openAsItem = popup.menu.findItem(R.id.status_open_as) + when (accounts.size) { + 0, 1 -> openAsItem.isVisible = false + 2 -> for (account in accounts) { + if (account !== viewModel.activeAccount) { + openAsTitle = String.format(getString(R.string.action_open_as), account.fullName) + break + } + } + else -> openAsTitle = String.format(getString(R.string.action_open_as), "…") + } + openAsItem.title = openAsTitle + + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.status_share_content -> { + val statusToShare: Status = status.actionableStatus + + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + + val stringToShare = statusToShare.account.username + + " - " + + statusToShare.content.toString() + sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) + sendIntent.type = "text/plain" + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_content_to))) + return@setOnMenuItemClickListener true + } + R.id.status_share_link -> { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl) + sendIntent.type = "text/plain" + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_link_to))) + return@setOnMenuItemClickListener true + } + R.id.status_copy_link -> { + val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl)) + return@setOnMenuItemClickListener true + } + R.id.status_open_in_web -> { + LinkHelper.openLinkInBrowser(Uri.parse(statusUrl), context); + return@setOnMenuItemClickListener true + } + R.id.status_open_as -> { + showOpenAsDialog(statusUrl!!, item.title) + return@setOnMenuItemClickListener true + } + R.id.status_download_media -> { + requestDownloadAllMedia(status) + return@setOnMenuItemClickListener true + } + R.id.status_mute_conversation -> { + searchAdapter.getItem(position)?.let { foundStatus -> + viewModel.muteConversation(foundStatus.first, status.muted != true) + } + return@setOnMenuItemClickListener true + } + R.id.status_mute -> { + onMute(accountId, accountUsername) + return@setOnMenuItemClickListener true + } + R.id.status_block -> { + onBlock(accountId, accountUsername) + return@setOnMenuItemClickListener true + } + R.id.status_report -> { + openReportPage(accountId, accountUsername, id) + return@setOnMenuItemClickListener true + } + R.id.status_unreblog_private -> { + onReblog(false, position) + return@setOnMenuItemClickListener true + } + R.id.status_reblog_private -> { + onReblog(true, position) + return@setOnMenuItemClickListener true + } + R.id.status_delete -> { + showConfirmDeleteDialog(id, position) + return@setOnMenuItemClickListener true + } + R.id.pin -> { + viewModel.pinAccount(status, !status.isPinned()) + return@setOnMenuItemClickListener true + } + } + false + } + popup.show() + } + + private fun onBlock(accountId: String, accountUsername: String) { + AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.dialog_block_warning, accountUsername)) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.blockAccount(accountId) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun onMute(accountId: String, accountUsername: String) { + showMuteAccountDialog( + this.requireActivity(), + accountUsername + ) { notifications, duration -> + viewModel.muteAccount(accountId, notifications, duration) + } + } + + private fun accountIsInMentions(account: AccountEntity?, mentions: Array): Boolean { + return mentions.firstOrNull { + account?.username == it.username && account.domain == Uri.parse(it.url)?.host + } != null + } + + private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) { + bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + openAsAccount(statusUrl, account) + } + }) + } + + private fun openAsAccount(statusUrl: String, account: AccountEntity) { + viewModel.activeAccount = account + val intent = Intent(context, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(MainActivity.STATUS_URL, statusUrl) + startActivity(intent) + (activity as BaseActivity).finishWithoutSlideOutAnimation() + } + + private fun downloadAllMedia(status: Status) { + Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() + for ((_, url) in status.attachments) { + val uri = Uri.parse(url) + val filename = uri.lastPathSegment + + val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(uri) + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) + downloadManager.enqueue(request) + } + } + + private fun requestDownloadAllMedia(status: Status) { + val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) + (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status) + } else { + Toast.makeText(context, R.string.error_media_download_permission, Toast.LENGTH_SHORT).show() + } + } + } + + private fun openReportPage(accountId: String, accountUsername: String, statusId: String) { + startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId)) + } + + private fun showConfirmDeleteDialog(id: String, position: Int) { + context?.let { + AlertDialog.Builder(it) + .setMessage(R.string.dialog_delete_toot_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteStatus(id) + removeItem(position) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + private fun showConfirmEditDialog(id: String, position: Int, status: Status) { + activity?.let { + AlertDialog.Builder(it) + .setMessage(R.string.dialog_redraft_toot_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteStatus(id) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ deletedStatus -> + removeItem(position) + + val redraftStatus = if (deletedStatus.isEmpty()) { + status.toDeletedStatus() + } else { + deletedStatus + } + + val intent = ComposeActivity.startIntent(requireContext(), ComposeOptions( + tootText = redraftStatus.text ?: "", + inReplyToId = redraftStatus.inReplyToId, + visibility = redraftStatus.visibility, + contentWarning = redraftStatus.spoilerText, + mediaAttachments = redraftStatus.attachments, + sensitive = redraftStatus.sensitive, + poll = redraftStatus.poll?.toNewPoll(status.createdAt) + )) + startActivity(intent) + }, { error -> + Log.w("SearchStatusesFragment", "error deleting status", error) + Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() + }) + + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + override fun onEmojiReact(react: Boolean, emoji: String, statusId: String) { + viewModel.emojiReact(react, emoji, statusId) + } + + override fun onEmojiReactMenu(view: View, reaction: EmojiReaction, statusId: String) { + val context = requireContext() + val popup = PopupMenu(context, view) + + popup.inflate(R.menu.emoji_reaction_more) + popup.menu.findItem(R.id.emoji_react).isVisible = !reaction.me + popup.menu.findItem(R.id.emoji_unreact).isVisible = reaction.me + + popup.setOnMenuItemClickListener { item: MenuItem -> + when (item.itemId) { + R.id.emoji_react -> { + onEmojiReact(true, reaction.name, statusId) + return@setOnMenuItemClickListener true + } + R.id.emoji_unreact -> { + onEmojiReact(false, reaction.name, statusId) + return@setOnMenuItemClickListener true + } + R.id.emoji_reacted_by -> { + val intent = newIntent(context, AccountListActivity.Type.REACTED, statusId, reaction.name) + (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + return@setOnMenuItemClickListener true + } + } + false + } + popup.show() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt new file mode 100644 index 0000000..e1c64e2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt @@ -0,0 +1,31 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.room.* + +@Dao +interface AccountDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(account: AccountEntity): Long + + @Delete + fun delete(account: AccountEntity) + + @Query("SELECT * FROM AccountEntity ORDER BY id ASC") + fun loadAll(): List + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt new file mode 100644 index 0000000..9064aaf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -0,0 +1,90 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.defaultTabs + +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Status + +@Entity(indices = [Index(value = ["domain", "accountId"], + unique = true)]) +@TypeConverters(Converters::class) +data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long, + val domain: String, + var accessToken: String, + var isActive: Boolean, + var accountId: String = "", + var username: String = "", + var displayName: String = "", + var profilePictureUrl: String = "", + var notificationsEnabled: Boolean = true, + var notificationsStreamingEnabled: Boolean = true, + var notificationsMentioned: Boolean = true, + var notificationsFollowed: Boolean = true, + var notificationsFollowRequested: Boolean = false, + var notificationsReblogged: Boolean = true, + var notificationsFavorited: Boolean = true, + var notificationsPolls: Boolean = true, + var notificationsEmojiReactions: Boolean = true, + var notificationsChatMessages: Boolean = true, + var notificationsSubscriptions: Boolean = true, + var notificationsMove: Boolean = true, + var notificationSound: Boolean = true, + var notificationVibration: Boolean = true, + var notificationLight: Boolean = true, + var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, + var defaultMediaSensitivity: Boolean = false, + var alwaysShowSensitiveMedia: Boolean = false, + var alwaysOpenSpoiler: Boolean = false, + var mediaPreviewEnabled: Boolean = true, + var lastNotificationId: String = "0", + var activeNotifications: String = "[]", + var emojis: List = emptyList(), + var tabPreferences: List = defaultTabs(), + var notificationsFilter: String = "[]", + var defaultFormattingSyntax: String = "") { + + val identifier: String + get() = "$domain:$accountId" + + val fullName: String + get() = "@$username@$domain" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AccountEntity + + if (id == other.id) return true + if (domain == other.domain && accountId == other.accountId) return true + + return false + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + domain.hashCode() + result = 31 * result + accountId.hashCode() + return result + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt new file mode 100644 index 0000000..2e532c2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -0,0 +1,203 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import android.util.Log +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Status +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.Comparator + +/** + * This class caches the account database and handles all account related operations + * @author ConnyDuck + */ + +private const val TAG = "AccountManager" + +@Singleton +class AccountManager @Inject constructor(db: AppDatabase) { + + @Volatile + var activeAccount: AccountEntity? = null + + var accounts: MutableList = mutableListOf() + private set + private val accountDao: AccountDao = db.accountDao() + + init { + accounts = accountDao.loadAll().toMutableList() + + activeAccount = accounts.find { acc -> + acc.isActive + } + } + + /** + * Adds a new empty account and makes it the active account. + * More account information has to be added later with [updateActiveAccount] + * or the account wont be saved to the database. + * @param accessToken the access token for the new account + * @param domain the domain of the accounts Mastodon instance + */ + fun addAccount(accessToken: String, domain: String) { + + activeAccount?.let { + it.isActive = false + Log.d(TAG, "addAccount: saving account with id " + it.id) + + accountDao.insertOrReplace(it) + } + + val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 + val newAccountId = maxAccountId + 1 + activeAccount = AccountEntity(id = newAccountId, domain = domain.toLowerCase(Locale.ROOT), accessToken = accessToken, isActive = true) + + } + + /** + * Saves an already known account to the database. + * New accounts must be created with [addAccount] + * @param account the account to save + */ + fun saveAccount(account: AccountEntity) { + if (account.id != 0L) { + Log.d(TAG, "saveAccount: saving account with id " + account.id) + accountDao.insertOrReplace(account) + } + + } + + /** + * Logs the current account out by deleting all data of the account. + * @return the new active account, or null if no other account was found + */ + fun logActiveAccountOut(): AccountEntity? { + + if (activeAccount == null) { + return null + } else { + accounts.remove(activeAccount!!) + accountDao.delete(activeAccount!!) + + if (accounts.size > 0) { + accounts[0].isActive = true + activeAccount = accounts[0] + Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id) + accountDao.insertOrReplace(accounts[0]) + } else { + activeAccount = null + } + return activeAccount + + } + + } + + /** + * updates the current account with new information from the mastodon api + * and saves it in the database + * @param account the [Account] object returned from the api + */ + fun updateActiveAccount(account: Account) { + activeAccount?.let { + it.accountId = account.id + it.username = account.username + it.displayName = account.name + it.profilePictureUrl = account.avatar + it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC + it.defaultMediaSensitivity = account.source?.sensitive ?: false + it.emojis = account.emojis ?: emptyList() + + Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) + it.id = accountDao.insertOrReplace(it) + + val accountIndex = accounts.indexOf(it) + + if (accountIndex != -1) { + //in case the user was already logged in with this account, remove the old information + accounts.removeAt(accountIndex) + accounts.add(accountIndex, it) + } else { + accounts.add(it) + } + + } + } + + /** + * changes the active account + * @param accountId the database id of the new active account + */ + fun setActiveAccount(accountId: Long) { + + activeAccount?.let { + Log.d(TAG, "setActiveAccount: saving account with id " + it.id) + it.isActive = false + saveAccount(it) + } + + activeAccount = accounts.find { (id) -> + id == accountId + } + + activeAccount?.let { + it.isActive = true + accountDao.insertOrReplace(it) + } + } + + /** + * @return an immutable list of all accounts in the database with the active account first + */ + fun getAllAccountsOrderedByActive(): List { + val accountsCopy = accounts.toMutableList() + accountsCopy.sortWith(Comparator { l, r -> + when { + l.isActive && !r.isActive -> -1 + r.isActive && !l.isActive -> 1 + else -> 0 + } + }) + + return accountsCopy + } + + /** + * @return true if at least one account has notifications enabled + */ + fun areNotificationsEnabled(): Boolean { + return accounts.any { it.notificationsEnabled } + } + + fun areNotificationsStreamingEnabled() : Boolean { + return accounts.any { it.notificationsStreamingEnabled } + } + + /** + * Finds an account by its database id + * @param accountId the id of the account + * @return the requested account or null if it was not found + */ + fun getAccountById(accountId: Long): AccountEntity? { + return accounts.find { (id) -> + id == accountId + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java new file mode 100644 index 0000000..d36130d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -0,0 +1,408 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db; + +import androidx.annotation.NonNull; +import androidx.room.Database; +import androidx.room.RoomDatabase; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import com.keylesspalace.tusky.TabDataKt; +import com.keylesspalace.tusky.components.conversation.ConversationEntity; + +/** + * DB version & declare DAO + */ + +@Database(entities = { TootEntity.class, DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, + TimelineAccountEntity.class, ConversationEntity.class, ChatEntity.class, ChatMessageEntity.class + }, version = 27) +public abstract class AppDatabase extends RoomDatabase { + + public abstract TootDao tootDao(); + public abstract AccountDao accountDao(); + public abstract InstanceDao instanceDao(); + public abstract ConversationsDao conversationDao(); + public abstract TimelineDao timelineDao(); + public abstract ChatsDao chatsDao(); + public abstract DraftDao draftDao(); + + public static final Migration MIGRATION_2_3 = new Migration(2, 3) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE TootEntity2 (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, text TEXT, urls TEXT, contentWarning TEXT);"); + database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;"); + database.execSQL("DROP TABLE TootEntity;"); + database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;"); + } + }; + + public static final Migration MIGRATION_3_4 = new Migration(3, 4) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToId TEXT"); + database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToText TEXT"); + database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToUsername TEXT"); + database.execSQL("ALTER TABLE TootEntity ADD COLUMN visibility INTEGER"); + } + }; + + public static final Migration MIGRATION_4_5 = new Migration(4, 5) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE `AccountEntity` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, " + + "`isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + + "`profilePictureUrl` TEXT NOT NULL, " + + "`notificationsEnabled` INTEGER NOT NULL, " + + "`notificationsMentioned` INTEGER NOT NULL, " + + "`notificationsFollowed` INTEGER NOT NULL, " + + "`notificationsReblogged` INTEGER NOT NULL, " + + "`notificationsFavorited` INTEGER NOT NULL, " + + "`notificationSound` INTEGER NOT NULL, " + + "`notificationVibration` INTEGER NOT NULL, " + + "`notificationLight` INTEGER NOT NULL, " + + "`lastNotificationId` TEXT NOT NULL, " + + "`activeNotifications` TEXT NOT NULL)"); + database.execSQL("CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `AccountEntity` (`domain`, `accountId`)"); + } + }; + + public static final Migration MIGRATION_5_6 = new Migration(5, 6) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `EmojiListEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`instance`))"); + } + }; + + public static final Migration MIGRATION_6_7 = new Migration(6, 7) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `InstanceEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))"); + database.execSQL("INSERT OR REPLACE INTO `InstanceEntity` SELECT `instance`,`emojiList`, NULL FROM `EmojiListEntity`;"); + database.execSQL("DROP TABLE `EmojiListEntity`;"); + } + }; + + public static final Migration MIGRATION_7_8 = new Migration(7, 8) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `emojis` TEXT NOT NULL DEFAULT '[]'"); + } + }; + + public static final Migration MIGRATION_8_9 = new Migration(8, 9) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `descriptions` TEXT DEFAULT '[]'"); + } + }; + + public static final Migration MIGRATION_9_10 = new Migration(9, 10) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostPrivacy` INTEGER NOT NULL DEFAULT 1"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultMediaSensitivity` INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysShowSensitiveMedia` INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `mediaPreviewEnabled` INTEGER NOT NULL DEFAULT '1'"); + } + }; + + public static final Migration MIGRATION_10_11 = new Migration(10, 11) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`instance` TEXT NOT NULL, " + + "`localUsername` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, " + + "`displayName` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`avatar` TEXT NOT NULL, " + + "`emojis` TEXT NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`))"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`url` TEXT, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`authorServerId` TEXT," + + "`instance` TEXT, " + + "`inReplyToId` TEXT, " + + "`inReplyToAccountId` TEXT, " + + "`content` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`emojis` TEXT, " + + "`reblogsCount` INTEGER NOT NULL, " + + "`favouritesCount` INTEGER NOT NULL, " + + "`reblogged` INTEGER NOT NULL, " + + "`favourited` INTEGER NOT NULL, " + + "`sensitive` INTEGER NOT NULL, " + + "`spoilerText` TEXT, " + + "`visibility` INTEGER, " + + "`attachments` TEXT, " + + "`mentions` TEXT, " + + "`application` TEXT, " + + "`reblogServerId` TEXT, " + + "`reblogAccountId` TEXT," + + " PRIMARY KEY(`serverId`, `timelineUserId`)," + + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); + database.execSQL("CREATE INDEX IF NOT EXISTS" + + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); + } + }; + + public static final Migration MIGRATION_11_12 = new Migration(11, 12) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + String defaultTabs = TabDataKt.HOME + ";" + + TabDataKt.NOTIFICATIONS + ";" + + TabDataKt.LOCAL + ";" + + TabDataKt.FEDERATED; + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `tabPreferences` TEXT NOT NULL DEFAULT '" + defaultTabs + "'"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + + "`accountId` INTEGER NOT NULL, " + + "`id` TEXT NOT NULL, " + + "`accounts` TEXT NOT NULL, " + + "`unread` INTEGER NOT NULL, " + + "`s_id` TEXT NOT NULL, " + + "`s_url` TEXT, " + + "`s_inReplyToId` TEXT, " + + "`s_inReplyToAccountId` TEXT, " + + "`s_account` TEXT NOT NULL, " + + "`s_content` TEXT NOT NULL, " + + "`s_createdAt` INTEGER NOT NULL, " + + "`s_emojis` TEXT NOT NULL, " + + "`s_favouritesCount` INTEGER NOT NULL, " + + "`s_favourited` INTEGER NOT NULL, " + + "`s_sensitive` INTEGER NOT NULL, " + + "`s_spoilerText` TEXT NOT NULL, " + + "`s_attachments` TEXT NOT NULL, " + + "`s_mentions` TEXT NOT NULL, " + + "`s_showingHiddenContent` INTEGER NOT NULL, " + + "`s_expanded` INTEGER NOT NULL, " + + "`s_collapsible` INTEGER NOT NULL, " + + "`s_collapsed` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`, `accountId`))"); + + } + }; + + public static final Migration MIGRATION_12_13 = new Migration(12, 13) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + + database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); + database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`localUsername` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, " + + "`displayName` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`avatar` TEXT NOT NULL, " + + "`emojis` TEXT NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`))"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`url` TEXT, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`authorServerId` TEXT," + + "`inReplyToId` TEXT, " + + "`inReplyToAccountId` TEXT, " + + "`content` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`emojis` TEXT, " + + "`reblogsCount` INTEGER NOT NULL, " + + "`favouritesCount` INTEGER NOT NULL, " + + "`reblogged` INTEGER NOT NULL, " + + "`favourited` INTEGER NOT NULL, " + + "`sensitive` INTEGER NOT NULL, " + + "`spoilerText` TEXT, " + + "`visibility` INTEGER, " + + "`attachments` TEXT, " + + "`mentions` TEXT, " + + "`application` TEXT, " + + "`reblogServerId` TEXT, " + + "`reblogAccountId` TEXT," + + " PRIMARY KEY(`serverId`, `timelineUserId`)," + + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); + database.execSQL("CREATE INDEX IF NOT EXISTS" + + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); + } + }; + + public static final Migration MIGRATION_10_13 = new Migration(10, 13) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + MIGRATION_11_12.migrate(database); + MIGRATION_12_13.migrate(database); + } + }; + + public static final Migration MIGRATION_13_14 = new Migration(13, 14) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'"); + } + }; + + public static final Migration MIGRATION_14_15 = new Migration(14, 15) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT"); + } + }; + + public static final Migration MIGRATION_15_16 = new Migration(15, 16) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsPolls` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_16_17 = new Migration(16, 17) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineAccountEntity` ADD COLUMN `bot` INTEGER NOT NULL DEFAULT 0"); + } + }; + + public static final Migration MIGRATION_17_18 = new Migration(17, 18) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0"); + } + }; + + public static final Migration MIGRATION_18_19 = new Migration(18, 19) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptions` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptionLength` INTEGER"); + + database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `poll` TEXT"); + } + }; + + public static final Migration MIGRATION_19_20 = new Migration(19, 20) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0"); + } + + }; + + public static final Migration MIGRATION_20_21 = new Migration(20, 21) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT"); + database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `markdownMode` INTEGER"); + } + }; + + public static final Migration MIGRATION_21_22 = new Migration(21, 22) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsEmojiReactions` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_22_23 = new Migration(22, 23) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + // leave markdownMode unused, we don't need it anymore but don't recreate table + // database.execSQL("ALTER TABLE `TootEntity` DROP COLUMN `markdownMode`"); + database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `formattingSyntax` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultFormattingSyntax` TEXT NOT NULL DEFAULT ''"); + } + }; + + public static final Migration MIGRATION_23_24 = new Migration(23, 24) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_24_25 = new Migration(24, 25) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE `ChatEntity` (`localId` INTEGER NOT NULL," + + "`chatId` TEXT NOT NULL," + + "`accountId` TEXT NOT NULL," + + "`unread` INTEGER NOT NULL," + + "`updatedAt` INTEGER NOT NULL," + + "`lastMessageId` TEXT," + + "PRIMARY KEY (`localId`, `chatId`))"); + database.execSQL("CREATE TABLE `ChatMessageEntity` (`localId` INTEGER NOT NULL," + + "`messageId` TEXT NOT NULL," + + "`content` TEXT," + + "`chatId` TEXT NOT NULL," + + "`accountId` TEXT NOT NULL," + + "`createdAt` INTEGER NOT NULL," + + "`attachment` TEXT," + + "`emojis` TEXT NOT NULL," + + "PRIMARY KEY (`localId`, `messageId`))"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `chatLimit` INTEGER"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsChatMessages` INTEGER NOT NULL DEFAULT 1"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsStreamingEnabled` INTEGER NOT NULL DEFAULT 1"); + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `pleroma` TEXT"); + } + }; + + public static final Migration MIGRATION_25_26 = new Migration(25, 26) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSubscriptions` INTEGER NOT NULL DEFAULT 1"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsMove` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_26_27 = new Migration(26, 27) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `DraftEntity` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`accountId` INTEGER NOT NULL, " + + "`inReplyToId` TEXT," + + "`content` TEXT," + + "`contentWarning` TEXT," + + "`sensitive` INTEGER NOT NULL," + + "`visibility` INTEGER NOT NULL," + + "`attachments` TEXT NOT NULL," + + "`poll` TEXT," + + "`formattingSyntax` TEXT NOT NULL," + + "`failedToSend` INTEGER NOT NULL)" + ); + } + }; +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ChatEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/ChatEntity.kt new file mode 100644 index 0000000..1bba2ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/ChatEntity.kt @@ -0,0 +1,21 @@ +package com.keylesspalace.tusky.db + +import androidx.room.* + +@Entity( + primaryKeys = ["localId", "chatId"] +) +data class ChatEntity ( + val localId: Long, /* our user account id */ + val chatId: String, + val accountId: String, + val unread: Long, + val updatedAt: Long, + val lastMessageId: String? +) + +data class ChatEntityWithAccount ( + @Embedded val chat: ChatEntity, + @Embedded(prefix = "a_") val account: TimelineAccountEntity?, + @Embedded(prefix = "msg_") val lastMessage: ChatMessageEntity? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ChatMessageEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/ChatMessageEntity.kt new file mode 100644 index 0000000..9b43d66 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/ChatMessageEntity.kt @@ -0,0 +1,21 @@ +package com.keylesspalace.tusky.db + +import androidx.room.Entity + +/* + * ChatMessage model + */ + +@Entity( + primaryKeys = ["localId", "messageId"] +) +data class ChatMessageEntity( + val localId: Long, + val messageId: String, + val content: String?, + val chatId: String, + val accountId: String, + val createdAt: Long, + val attachment: String?, + val emojis: String +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ChatsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ChatsDao.kt new file mode 100644 index 0000000..8b0e7fc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/ChatsDao.kt @@ -0,0 +1,84 @@ +package com.keylesspalace.tusky.db + +import androidx.room.* +import androidx.room.OnConflictStrategy.IGNORE +import androidx.room.OnConflictStrategy.REPLACE +import io.reactivex.Single + +@Dao +abstract class ChatsDao { + + @Query("""SELECT c.chatId, c.localId, c.accountId, c.lastMessageId, c.unread, c.updatedAt, +a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.emojis as 'a_emojis', a.bot as 'a_bot', +msg.accountId as 'msg_accountId', msg.localId as 'msg_localId', +msg.chatId as 'msg_chatId', msg.attachment as 'msg_attachment', +msg.content as 'msg_content', msg.createdAt as 'msg_createdAt', msg.emojis as 'msg_emojis', +msg.messageId as 'msg_messageId' +FROM ChatEntity c +LEFT JOIN TimelineAccountEntity a ON (a.timelineUserId == :localId AND a.serverId = c.accountId) +LEFT JOIN ChatMessageEntity msg ON (msg.localId == :localId AND msg.chatId == c.chatId) +WHERE c.localId = :localId +AND (CASE WHEN :maxId IS NOT NULL THEN +(LENGTH(c.chatId) < LENGTH(:maxId) OR LENGTH(c.chatId) == LENGTH(:maxId) AND c.chatId < :maxId) +ELSE 1 END) +AND (CASE WHEN :sinceId IS NOT NULL THEN +(LENGTH(c.chatId) > LENGTH(:sinceId) OR LENGTH(c.chatId) == LENGTH(:sinceId) AND c.chatId > :sinceId) +ELSE 1 END) +ORDER BY c.updatedAt DESC +LIMIT :limit + """) + abstract fun getChatsForAccount(localId: Long, maxId: String?, sinceId: String?, limit: Int) : Single> + + @Insert(onConflict = REPLACE) + abstract fun insertChat(chatEntity: ChatEntity) : Long + + @Insert(onConflict = IGNORE) + abstract fun insertChatIfNotThere(chatEntity: ChatEntity): Long + + @Insert(onConflict = REPLACE) + abstract fun insertAccount(accountEntity: TimelineAccountEntity) : Long + + @Insert(onConflict = REPLACE) + abstract fun insertChatMessage(chatMessageEntity: ChatMessageEntity) : Long + + @Transaction + open fun insertInTransaction(chatEntity: ChatEntity, lastMessage: ChatMessageEntity?, accountEntity: TimelineAccountEntity) { + insertAccount(accountEntity) + lastMessage?.let(this::insertChatMessage) + insertChat(chatEntity) + } + + @Transaction + open fun setLastMessage(accountId: Long, chatId: String, lastMessageEntity: ChatMessageEntity) { + insertChatMessage(lastMessageEntity) + setLastMessageId(accountId, chatId, lastMessageEntity.messageId) + } + + @Query("""UPDATE ChatEntity SET lastMessageId = :messageId WHERE localId = :localId AND chatId = :chatId""") + abstract fun setLastMessageId(localId: Long, chatId: String, messageId: String) + + @Query("""DELETE FROM ChatEntity WHERE accountId = "" +AND localId = :account AND +(LENGTH(chatId) < LENGTH(:maxId) OR LENGTH(chatId) == LENGTH(:maxId) AND chatId < :maxId) +AND +(LENGTH(chatId) > LENGTH(:sinceId) OR LENGTH(chatId) == LENGTH(:sinceId) AND chatId > :sinceId) +""") + abstract fun removeAllPlaceholdersBetween(account: Long, maxId: String, sinceId: String) + + @Query("""DELETE FROM ChatEntity WHERE localId = :accountId AND +(LENGTH(chatId) < LENGTH(:maxId) OR LENGTH(chatId) == LENGTH(:maxId) AND chatId < :maxId) +AND +(LENGTH(chatId) > LENGTH(:minId) OR LENGTH(chatId) == LENGTH(:minId) AND chatId > :minId) + """) + abstract fun deleteRange(accountId: Long, minId: String, maxId: String) + + + @Query("""DELETE FROM ChatEntity WHERE localId = :localId AND accountId = :accountId""") + abstract fun deleteChatByAccount(localId: Long, accountId: String) + + @Query("""DELETE FROM ChatEntity WHERE localId = :localId AND chatId = :chatId""") + abstract fun deleteChat(localId: Long, chatId: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt new file mode 100644 index 0000000..00f32f5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -0,0 +1,41 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.paging.DataSource +import androidx.room.* +import com.keylesspalace.tusky.components.conversation.ConversationEntity +import io.reactivex.Single + +@Dao +interface ConversationsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(conversations: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(conversation: ConversationEntity): Single + + @Delete + fun delete(conversation: ConversationEntity): Single + + @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") + fun conversationsForAccount(accountId: Long) : DataSource.Factory + + @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") + fun deleteForAccount(accountId: Long) + + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt new file mode 100644 index 0000000..1b1f94f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -0,0 +1,170 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import android.text.Spanned +import androidx.core.text.parseAsHtml +import androidx.core.text.toHtml +import androidx.room.TypeConverter +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity +import com.keylesspalace.tusky.createTabDataFromId +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.json.SpannedTypeAdapter +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.* + +class Converters { + + private val gson = GsonBuilder() + .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) + .create() + + @TypeConverter + fun jsonToEmojiList(emojiListJson: String?): List? { + return gson.fromJson(emojiListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun emojiListToJson(emojiList: List?): String { + return gson.toJson(emojiList) + } + + @TypeConverter + fun visibilityToInt(visibility: Status.Visibility?): Int { + return visibility?.num ?: Status.Visibility.UNKNOWN.num + } + + @TypeConverter + fun intToVisibility(visibility: Int): Status.Visibility { + return Status.Visibility.byNum(visibility) + } + + @TypeConverter + fun stringToTabData(str: String?): List? { + return str?.split(";") + ?.map { + val data = it.split(":") + createTabDataFromId(data[0], data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") }) + } + } + + @TypeConverter + fun tabDataToString(tabData: List?): String? { + // List name may include ":" + return tabData?.joinToString(";") { it.id + ":" + it.arguments.joinToString(":") { s -> URLEncoder.encode(s, "UTF-8") } } + } + + @TypeConverter + fun accountToJson(account: ConversationAccountEntity?): String { + return gson.toJson(account) + } + + @TypeConverter + fun jsonToAccount(accountJson: String?): ConversationAccountEntity? { + return gson.fromJson(accountJson, ConversationAccountEntity::class.java) + } + + @TypeConverter + fun accountListToJson(accountList: List?): String { + return gson.toJson(accountList) + } + + @TypeConverter + fun jsonToAccountList(accountListJson: String?): List? { + return gson.fromJson(accountListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun attachmentListToJson(attachmentList: List?): String { + return gson.toJson(attachmentList) + } + + @TypeConverter + fun jsonToAttachmentList(attachmentListJson: String?): ArrayList? { + return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun mentionArrayToJson(mentionArray: Array?): String? { + return gson.toJson(mentionArray) + } + + @TypeConverter + fun jsonToMentionArray(mentionListJson: String?): Array? { + return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun dateToLong(date: Date): Long { + return date.time + } + + @TypeConverter + fun longToDate(date: Long): Date { + return Date(date) + } + + @TypeConverter + fun spannedToString(spanned: Spanned?): String? { + if(spanned == null) { + return null + } + return spanned.toHtml() + } + + @TypeConverter + fun stringToSpanned(spannedString: String?): Spanned? { + if(spannedString == null) { + return null + } + return spannedString.parseAsHtml().trimTrailingWhitespace() + } + + @TypeConverter + fun pollToJson(poll: Poll?): String? { + return gson.toJson(poll) + } + + @TypeConverter + fun jsonToPoll(pollJson: String?): Poll? { + return gson.fromJson(pollJson, Poll::class.java) + } + + @TypeConverter + fun newPollToJson(newPoll: NewPoll?): String? { + return gson.toJson(newPoll) + } + + @TypeConverter + fun jsonToNewPoll(newPollJson: String?): NewPoll? { + return gson.fromJson(newPollJson, NewPoll::class.java) + } + + @TypeConverter + fun draftAttachmentListToJson(draftAttachments: List?): String? { + return gson.toJson(draftAttachments) + } + + @TypeConverter + fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { + return gson.fromJson(draftAttachmentListJson, object : TypeToken>() {}.type) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt new file mode 100644 index 0000000..105fd7c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt @@ -0,0 +1,40 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.reactivex.Completable +import io.reactivex.Single + +@Dao +interface DraftDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(draft: DraftEntity): Completable + + @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC") + fun loadDrafts(accountId: Long): DataSource.Factory + + @Query("DELETE FROM DraftEntity WHERE id = :id") + fun delete(id: Int): Completable + + @Query("SELECT * FROM DraftEntity WHERE id = :id") + fun find(id: Int): Single +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt new file mode 100644 index 0000000..9f06740 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -0,0 +1,56 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import android.net.Uri +import android.os.Parcelable +import androidx.core.net.toUri +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import kotlinx.android.parcel.Parcelize + +@Entity +@TypeConverters(Converters::class) +data class DraftEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val accountId: Long, + val inReplyToId: String?, + val content: String?, + val contentWarning: String?, + val sensitive: Boolean, + val visibility: Status.Visibility, + val attachments: List, + val poll: NewPoll?, + val formattingSyntax: String, + val failedToSend: Boolean +) + +@Parcelize +data class DraftAttachment( + val uriString: String, + val description: String?, + val type: Type +): Parcelable { + val uri: Uri + get() = uriString.toUri() + + enum class Type { + IMAGE, VIDEO, AUDIO, UNKNOWN; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt new file mode 100644 index 0000000..0c78349 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -0,0 +1,31 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.reactivex.Single + +@Dao +interface InstanceDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(instance: InstanceEntity) + + @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") + fun loadMetadataForInstance(instance: String): Single +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt new file mode 100644 index 0000000..0d90c29 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -0,0 +1,33 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.keylesspalace.tusky.entity.Emoji + +@Entity +@TypeConverters(Converters::class) +data class InstanceEntity( + @field:PrimaryKey var instance: String, + val emojiList: List?, + val maximumTootCharacters: Int?, + val maxPollOptions: Int?, + val maxPollOptionLength: Int?, + val version: String?, + val chatLimit: Int? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt new file mode 100644 index 0000000..1c9f4e1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -0,0 +1,111 @@ +package com.keylesspalace.tusky.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.IGNORE +import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.Single + +@Dao +abstract class TimelineDao { + + @Insert(onConflict = REPLACE) + abstract fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long + + @Insert(onConflict = REPLACE) + abstract fun insertStatus(timelineAccountEntity: TimelineStatusEntity): Long + + + @Insert(onConflict = IGNORE) + abstract fun insertStatusIfNotThere(timelineAccountEntity: TimelineStatusEntity): Long + + @Query(""" +SELECT s.serverId, s.url, s.timelineUserId, +s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, +s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, +s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId, +s.content, s.attachments, s.poll, s.pleroma, +a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.emojis as 'a_emojis', a.bot as 'a_bot', +rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', +rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', +rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', +rb.emojis as'rb_emojis', rb.bot as 'rb_bot' +FROM TimelineStatusEntity s +LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) +LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) +WHERE s.timelineUserId = :account +AND (CASE WHEN :maxId IS NOT NULL THEN +(LENGTH(s.serverId) < LENGTH(:maxId) OR LENGTH(s.serverId) == LENGTH(:maxId) AND s.serverId < :maxId) +ELSE 1 END) +AND (CASE WHEN :sinceId IS NOT NULL THEN +(LENGTH(s.serverId) > LENGTH(:sinceId) OR LENGTH(s.serverId) == LENGTH(:sinceId) AND s.serverId > :sinceId) +ELSE 1 END) +ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC +LIMIT :limit""") + abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single> + + @Transaction + open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity, + reblogAccount: TimelineAccountEntity?) { + insertAccount(account) + reblogAccount?.let(this::insertAccount) + insertStatus(status) + } + + @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND + (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) +AND +(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId > :minId) + """) + abstract fun deleteRange(accountId: Long, minId: String, maxId: String) + + @Query("""DELETE FROM TimelineStatusEntity WHERE authorServerId = null +AND timelineUserId = :account AND +(LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) +AND +(LENGTH(serverId) > LENGTH(:sinceId) OR LENGTH(serverId) == LENGTH(:sinceId) AND serverId > :sinceId) +""") + abstract fun removeAllPlaceholdersBetween(account: Long, maxId: String, sinceId: String) + + @Query("""UPDATE TimelineStatusEntity SET favourited = :favourited +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + abstract fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) + + @Query("""UPDATE TimelineStatusEntity SET bookmarked = :bookmarked +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + abstract fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) + + @Query("""UPDATE TimelineStatusEntity SET reblogged = :reblogged +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + abstract fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) + + @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND +(authorServerId = :userId OR reblogAccountId = :userId)""") + abstract fun removeAllByUser(accountId: Long, userId: String) + + @Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId") + abstract fun removeAllForAccount(accountId: Long) + + @Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId") + abstract fun removeAllUsersForAccount(accountId: Long) + + @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId +AND serverId = :statusId""") + abstract fun delete(accountId: Long, statusId: String) + + @Query("""DELETE FROM TimelineStatusEntity WHERE createdAt < :olderThan""") + abstract fun cleanup(olderThan: Long) + + @Query("""UPDATE TimelineStatusEntity SET poll = :poll +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + abstract fun setVoted(accountId: Long, statusId: String, poll: String) + + @Query("""UPDATE TimelineStatusEntity SET pleroma = :pleroma +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + abstract fun setPleroma(accountId: Long, statusId: String, pleroma: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt new file mode 100644 index 0000000..4893708 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -0,0 +1,81 @@ +package com.keylesspalace.tusky.db + +import androidx.room.* +import com.keylesspalace.tusky.entity.Status + +/** + * We're trying to play smart here. Server sends us reblogs as two entities one embedded into + * another (reblogged status is a field inside of "reblog" status). But it's really inefficient from + * the DB perspective and doesn't matter much for the display/interaction purposes. + * What if when we store reblog we don't store almost empty "reblog status" but we store + * *reblogged* status and we embed "reblog status" into reblogged status. This reversed + * relationship takes much less space and is much faster to fetch (no N+1 type queries or JSON + * serialization). + * "Reblog status", if present, is marked by [reblogServerId], and [reblogAccountId] + * fields. + */ +@Entity( + primaryKeys = ["serverId", "timelineUserId"], + foreignKeys = ([ + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "timelineUserId"], + childColumns = ["authorServerId", "timelineUserId"] + ) + ]), + // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). + indices = [Index("authorServerId", "timelineUserId")] +) +@TypeConverters(Converters::class) +data class TimelineStatusEntity( + val serverId: String, // id never flips: we need it for sorting so it's a real id + val url: String?, + // our local id for the logged in user in case there are multiple accounts per instance + val timelineUserId: Long, + val authorServerId: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val content: String?, + val createdAt: Long, + val emojis: String?, + val reblogsCount: Int, + val favouritesCount: Int, + val reblogged: Boolean, + val bookmarked: Boolean, + val favourited: Boolean, + val sensitive: Boolean, + val spoilerText: String?, + val visibility: Status.Visibility?, + val attachments: String?, + val mentions: String?, + val application: String?, + val reblogServerId: String?, // if it has a reblogged status, it's id is stored here + val reblogAccountId: String?, + val poll: String?, + val pleroma: String? +) + +@Entity( + primaryKeys = ["serverId", "timelineUserId"] +) +data class TimelineAccountEntity( + val serverId: String, + val timelineUserId: Long, + val localUsername: String, + val username: String, + val displayName: String, + val url: String, + val avatar: String, + val emojis: String, + val bot: Boolean +) + + +class TimelineStatusWithAccount { + @Embedded + lateinit var status: TimelineStatusEntity + @Embedded(prefix = "a_") + lateinit var account: TimelineAccountEntity + @Embedded(prefix = "rb_") + var reblogAccount: TimelineAccountEntity? = null +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java b/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java new file mode 100644 index 0000000..f46c275 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java @@ -0,0 +1,45 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db; + +import androidx.room.Dao; +import androidx.room.Query; + +import java.util.List; + +import io.reactivex.Observable; + +/** + * Created by cto3543 on 28/06/2017. + * + * DAO to fetch and update toots in the DB. + */ + +@Dao +public interface TootDao { + + @Query("SELECT * FROM TootEntity ORDER BY uid DESC") + List loadAll(); + + @Query("DELETE FROM TootEntity WHERE uid = :uid") + int delete(int uid); + + @Query("SELECT * FROM TootEntity WHERE uid = :uid") + TootEntity find(int uid); + + @Query("SELECT COUNT(*) FROM TootEntity") + Observable savedTootCount(); +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java new file mode 100644 index 0000000..d40bcc7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java @@ -0,0 +1,170 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db; + +import com.google.gson.Gson; +import com.keylesspalace.tusky.entity.NewPoll; +import com.keylesspalace.tusky.entity.Status; + +import androidx.annotation.*; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; +import androidx.room.TypeConverter; +import androidx.room.TypeConverters; + +/** + * Toot model. + */ + +@Entity +@TypeConverters(TootEntity.Converters.class) +public class TootEntity { + @PrimaryKey(autoGenerate = true) + private final int uid; + + @ColumnInfo(name = "text") + private final String text; + + @ColumnInfo(name = "urls") + private final String urls; + + @ColumnInfo(name = "descriptions") + private final String descriptions; + + @ColumnInfo(name = "contentWarning") + private final String contentWarning; + + @ColumnInfo(name = "inReplyToId") + private final String inReplyToId; + + @Nullable + @ColumnInfo(name = "inReplyToText") + private final String inReplyToText; + + @Nullable + @ColumnInfo(name = "inReplyToUsername") + private final String inReplyToUsername; + + @ColumnInfo(name = "visibility") + private final Status.Visibility visibility; + + @Nullable + @ColumnInfo(name = "poll") + private final NewPoll poll; + + @NonNull + @ColumnInfo(name = "formattingSyntax") + private final String formattingSyntax; + + /* DEPRECATED */ + @Nullable + @ColumnInfo(name = "markdownMode") + public Boolean markdownMode = false; + + public TootEntity(int uid, String text, String urls, String descriptions, String contentWarning, String inReplyToId, + @Nullable String inReplyToText, @Nullable String inReplyToUsername, + Status.Visibility visibility, @Nullable NewPoll poll, String formattingSyntax) { + this.uid = uid; + this.text = text; + this.urls = urls; + this.descriptions = descriptions; + this.contentWarning = contentWarning; + this.inReplyToId = inReplyToId; + this.inReplyToText = inReplyToText; + this.inReplyToUsername = inReplyToUsername; + this.visibility = visibility; + this.poll = poll; + this.formattingSyntax = formattingSyntax; + } + + public String getText() { + return text; + } + + public String getContentWarning() { + return contentWarning; + } + + public int getUid() { + return uid; + } + + public String getUrls() { + return urls; + } + + public String getDescriptions() { + return descriptions; + } + + public String getInReplyToId() { + return inReplyToId; + } + + @Nullable + public String getInReplyToText() { + return inReplyToText; + } + + @Nullable + public String getInReplyToUsername() { + return inReplyToUsername; + } + + public Status.Visibility getVisibility() { + return visibility; + } + + @Nullable + public NewPoll getPoll() { + return poll; + } + + public String getFormattingSyntax() { + return formattingSyntax; + } + + @Nullable + public Boolean getMarkdownMode() { + return markdownMode; + } + + public static final class Converters { + + private static final Gson gson = new Gson(); + + @TypeConverter + public Status.Visibility visibilityFromInt(int number) { + return Status.Visibility.byNum(number); + } + + @TypeConverter + public int intFromVisibility(Status.Visibility visibility) { + return visibility == null ? Status.Visibility.UNKNOWN.getNum() : visibility.getNum(); + } + + @TypeConverter + public String pollToString(NewPoll poll) { + return gson.toJson(poll); + } + + @TypeConverter + public NewPoll stringToPoll(String poll) { + return gson.fromJson(poll, NewPoll.class); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt new file mode 100644 index 0000000..47227cc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -0,0 +1,118 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.components.chat.ChatActivity +import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.components.instancemute.InstanceListActivity +import com.keylesspalace.tusky.components.preference.PreferencesActivity +import com.keylesspalace.tusky.components.report.ReportActivity +import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity +import com.keylesspalace.tusky.components.search.SearchActivity +import dagger.Module +import dagger.android.ContributesAndroidInjector + +/** + * Created by charlag on 3/24/18. + */ + +@Module +abstract class ActivitiesModule { + + @ContributesAndroidInjector + abstract fun contributesBaseActivity(): BaseActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesMainActivity(): MainActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesAccountActivity(): AccountActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesListsActivity(): ListsActivity + + @ContributesAndroidInjector + abstract fun contributesComposeActivity(): ComposeActivity + + @ContributesAndroidInjector + abstract fun contributesChatActivity(): ChatActivity + + @ContributesAndroidInjector + abstract fun contributesEditProfileActivity(): EditProfileActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesAccountListActivity(): AccountListActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesModalTimelineActivity(): ModalTimelineActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesViewTagActivity(): ViewTagActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesViewThreadActivity(): ViewThreadActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesStatusListActivity(): StatusListActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesSearchAvtivity(): SearchActivity + + @ContributesAndroidInjector + abstract fun contributesAboutActivity(): AboutActivity + + @ContributesAndroidInjector + abstract fun contributesLoginActivity(): LoginActivity + + @ContributesAndroidInjector + abstract fun contributesSplashActivity(): SplashActivity + + @ContributesAndroidInjector + abstract fun contributesSavedTootActivity(): SavedTootActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesPreferencesActivity(): PreferencesActivity + + @ContributesAndroidInjector + abstract fun contributesViewMediaActivity(): ViewMediaActivity + + @ContributesAndroidInjector + abstract fun contributesLicenseActivity(): LicenseActivity + + @ContributesAndroidInjector + abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity + + @ContributesAndroidInjector + abstract fun contributesFiltersActivity(): FiltersActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesReportActivity(): ReportActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesInstanceListActivity(): InstanceListActivity + + @ContributesAndroidInjector + abstract fun contributesScheduledTootActivity(): ScheduledTootActivity + + @ContributesAndroidInjector + abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity + + @ContributesAndroidInjector + abstract fun contributesDraftActivity(): DraftsActivity +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt new file mode 100644 index 0000000..c18362b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -0,0 +1,52 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import com.keylesspalace.tusky.TuskyApplication +import dagger.BindsInstance +import dagger.Component +import dagger.android.support.AndroidSupportInjectionModule +import javax.inject.Singleton + + +/** + * Created by charlag on 3/21/18. + */ + +@Singleton +@Component(modules = [ + AppModule::class, + NetworkModule::class, + AndroidSupportInjectionModule::class, + ActivitiesModule::class, + ServicesModule::class, + BroadcastReceiverModule::class, + ViewModelModule::class, + RepositoryModule::class, + MediaUploaderModule::class, + GlideModule::class +]) +interface AppComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun application(tuskyApp: TuskyApplication): Builder + + fun build(): AppComponent + } + + fun inject(app: TuskyApplication) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt new file mode 100644 index 0000000..bd06bfc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt @@ -0,0 +1,80 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import com.keylesspalace.tusky.TuskyApplication +import dagger.android.AndroidInjection +import dagger.android.HasAndroidInjector +import dagger.android.support.AndroidSupportInjection + +/** + * Created by charlag on 3/24/18. + */ + +object AppInjector { + fun init(app: TuskyApplication) { + DaggerAppComponent.builder().application(app) + .build().inject(app) + + app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + handleActivity(activity) + } + + override fun onActivityPaused(activity: Activity?) { + } + + override fun onActivityResumed(activity: Activity?) { + } + + override fun onActivityStarted(activity: Activity?) { + } + + override fun onActivityDestroyed(activity: Activity?) { + } + + override fun onActivitySaveInstanceState(activity: Activity?, outState: Bundle?) { + } + + override fun onActivityStopped(activity: Activity?) { + } + + }) + } + + private fun handleActivity(activity: Activity) { + if (activity is HasAndroidInjector || activity is Injectable) { + AndroidInjection.inject(activity) + } + if (activity is FragmentActivity) { + activity.supportFragmentManager.registerFragmentLifecycleCallbacks( + object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentPreAttached(fm: FragmentManager, f: Fragment, context: Context) { + if (f is Injectable) { + AndroidSupportInjection.inject(f) + } + } + }, true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt new file mode 100644 index 0000000..db1317e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -0,0 +1,91 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + + +package com.keylesspalace.tusky.di + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.preference.PreferenceManager +import androidx.room.Room +import com.keylesspalace.tusky.TuskyApplication +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.EventHubImpl +import com.keylesspalace.tusky.components.notifications.Notifier +import com.keylesspalace.tusky.components.notifications.SystemNotifier +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.network.TimelineCasesImpl +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +/** + * Created by charlag on 3/21/18. + */ + +@Module +class AppModule { + + @Provides + fun providesApplication(app: TuskyApplication): Application = app + + @Provides + fun providesContext(app: Application): Context = app + + @Provides + fun providesSharedPreferences(app: Application): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(app) + } + + @Provides + fun providesBroadcastManager(app: Application): LocalBroadcastManager { + return LocalBroadcastManager.getInstance(app) + } + + @Provides + fun providesTimelineUseCases(api: MastodonApi, + eventHub: EventHub): TimelineCases { + return TimelineCasesImpl(api, eventHub) + } + + @Provides + @Singleton + fun providesEventHub(): EventHub = EventHubImpl + + @Provides + @Singleton + fun providesDatabase(appContext: Context): AppDatabase { + return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB") + .allowMainThreadQueries() + .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, + AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, + AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, + AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, + AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, + AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, + AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, + AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, + AppDatabase.MIGRATION_25_26, AppDatabase.MIGRATION_26_27) + .build() + } + + @Provides + @Singleton + fun notifier(context: Context): Notifier = SystemNotifier(context) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt new file mode 100644 index 0000000..edf9534 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt @@ -0,0 +1,31 @@ +/* Copyright 2018 Jeremiasz Nelz + * Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver +import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver +import dagger.Module +import dagger.android.ContributesAndroidInjector + +@Module +abstract class BroadcastReceiverModule { + @ContributesAndroidInjector + abstract fun contributeSendStatusBroadcastReceiver() : SendStatusBroadcastReceiver + + @ContributesAndroidInjector + abstract fun contributeNotificationClearBroadcastReceiver() : NotificationClearBroadcastReceiver +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt new file mode 100644 index 0000000..ca95183 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -0,0 +1,95 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + + +package com.keylesspalace.tusky.di + +import com.keylesspalace.tusky.AccountsInListFragment +import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment +import com.keylesspalace.tusky.fragment.* +import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment +import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment +import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment +import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment +import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment +import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment +import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment +import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment +import com.keylesspalace.tusky.components.preference.PreferencesFragment +import dagger.Module +import dagger.android.ContributesAndroidInjector + +/** + * Created by charlag on 3/24/18. + */ + +@Module +abstract class FragmentBuildersModule { + @ContributesAndroidInjector + abstract fun accountListFragment(): AccountListFragment + + @ContributesAndroidInjector + abstract fun accountMediaFragment(): AccountMediaFragment + + @ContributesAndroidInjector + abstract fun viewThreadFragment(): ViewThreadFragment + + @ContributesAndroidInjector + abstract fun timelineFragment(): TimelineFragment + + @ContributesAndroidInjector + abstract fun chatsFragment(): ChatsFragment + + @ContributesAndroidInjector + abstract fun notificationsFragment(): NotificationsFragment + + @ContributesAndroidInjector + abstract fun searchFragment(): SearchStatusesFragment + + @ContributesAndroidInjector + abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment + + @ContributesAndroidInjector + abstract fun accountPreferencesFragment(): AccountPreferencesFragment + + @ContributesAndroidInjector + abstract fun directMessagesPreferencesFragment(): ConversationsFragment + + @ContributesAndroidInjector + abstract fun accountInListsFragment(): AccountsInListFragment + + @ContributesAndroidInjector + abstract fun reportStatusesFragment(): ReportStatusesFragment + + @ContributesAndroidInjector + abstract fun reportNoteFragment(): ReportNoteFragment + + @ContributesAndroidInjector + abstract fun reportDoneFragment(): ReportDoneFragment + + @ContributesAndroidInjector + abstract fun instanceListFragment(): InstanceListFragment + + @ContributesAndroidInjector + abstract fun searchAccountFragment(): SearchAccountsFragment + + @ContributesAndroidInjector + abstract fun searchHashtagsFragment(): SearchHashtagsFragment + + @ContributesAndroidInjector + abstract fun preferencesFragment(): PreferencesFragment + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/GlideModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/GlideModule.kt new file mode 100644 index 0000000..3a1bc0d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/GlideModule.kt @@ -0,0 +1,12 @@ +package com.keylesspalace.tusky.di + +import com.keylesspalace.tusky.util.OmittedDomainAppModule +import dagger.Module +import dagger.android.ContributesAndroidInjector + +@Module +abstract class GlideModule { + @ContributesAndroidInjector + abstract fun provideOmittedDomainAppModule() : OmittedDomainAppModule + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt b/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt new file mode 100644 index 0000000..1df715e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt @@ -0,0 +1,23 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + + +package com.keylesspalace.tusky.di + +/** + * Created by charlag on 3/24/18. + */ + +interface Injectable \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt new file mode 100644 index 0000000..641bab5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt @@ -0,0 +1,30 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import android.content.Context +import com.keylesspalace.tusky.components.common.MediaUploader +import com.keylesspalace.tusky.components.common.MediaUploaderImpl +import com.keylesspalace.tusky.network.MastodonApi +import dagger.Module +import dagger.Provides + +@Module +class MediaUploaderModule { + @Provides + fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader = + MediaUploaderImpl(context, mastodonApi) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt new file mode 100644 index 0000000..64611d4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -0,0 +1,89 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import android.content.Context +import android.text.Spanned +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.json.SpannedTypeAdapter +import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.OkHttpUtils +import dagger.Module +import dagger.Provides +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +/** + * Created by charlag on 3/24/18. + */ + +@Module +class NetworkModule { + + @Provides + @Singleton + fun providesGson(): Gson { + return GsonBuilder() + .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) + .create() + } + + @Provides + @Singleton + fun providesHttpClient( + accountManager: AccountManager, + context: Context + ): OkHttpClient { + return OkHttpUtils.getCompatibleClientBuilder(context) + .apply { + addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + //level = HttpLoggingInterceptor.Level.HEADERS + //level = HttpLoggingInterceptor.Level.BODY + }) + } + } + .build() + } + + @Provides + @Singleton + fun providesRetrofit( + httpClient: OkHttpClient, + gson: Gson + ): Retrofit { + return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) + .client(httpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync()) + .build() + + } + + @Provides + @Singleton + fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create(MastodonApi::class.java) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt new file mode 100644 index 0000000..7152a88 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt @@ -0,0 +1,35 @@ +package com.keylesspalace.tusky.di + +import com.google.gson.Gson +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.repository.ChatRepository +import com.keylesspalace.tusky.repository.ChatRepositoryImpl +import com.keylesspalace.tusky.repository.TimelineRepository +import com.keylesspalace.tusky.repository.TimelineRepositoryImpl +import dagger.Module +import dagger.Provides + +@Module +class RepositoryModule { + @Provides + fun providesTimelineRepository( + db: AppDatabase, + mastodonApi: MastodonApi, + accountManager: AccountManager, + gson: Gson + ): TimelineRepository { + return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson) + } + + @Provides + fun providesChatRepository( + db: AppDatabase, + mastodonApi: MastodonApi, + accountManager: AccountManager, + gson: Gson + ): ChatRepository { + return ChatRepositoryImpl(db.chatsDao(), mastodonApi, accountManager, gson) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt new file mode 100644 index 0000000..a2d6d46 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -0,0 +1,43 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import android.content.Context +import com.keylesspalace.tusky.service.SendTootService +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.service.ServiceClientImpl +import com.keylesspalace.tusky.service.StreamingService +import dagger.Module +import dagger.Provides +import dagger.android.ContributesAndroidInjector + +@Module +abstract class ServicesModule { + @ContributesAndroidInjector + abstract fun contributesSendTootService(): SendTootService + + @ContributesAndroidInjector + abstract fun contributesStreamingService(): StreamingService + + @Module + companion object { + @Provides + @JvmStatic + fun providesServiceClient(context: Context): ServiceClient { + return ServiceClientImpl(context) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt new file mode 100644 index 0000000..13cccdc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -0,0 +1,107 @@ +// from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 + +package com.keylesspalace.tusky.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.keylesspalace.tusky.components.chat.ChatViewModel +import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel +import com.keylesspalace.tusky.components.compose.ComposeViewModel +import com.keylesspalace.tusky.components.conversation.ConversationsViewModel +import com.keylesspalace.tusky.components.drafts.DraftsViewModel +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel +import com.keylesspalace.tusky.components.search.SearchViewModel +import com.keylesspalace.tusky.viewmodel.AccountViewModel +import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel +import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.keylesspalace.tusky.viewmodel.ListsViewModel +import dagger.Binds +import dagger.MapKey +import dagger.Module +import dagger.multibindings.IntoMap +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.reflect.KClass + +@Singleton +class ViewModelFactory @Inject constructor(private val viewModels: MutableMap, Provider>) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T +} + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +@kotlin.annotation.Retention(AnnotationRetention.RUNTIME) +@MapKey +internal annotation class ViewModelKey(val value: KClass) + +@Module +abstract class ViewModelModule { + + @Binds + internal abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory + + @Binds + @IntoMap + @ViewModelKey(AccountViewModel::class) + internal abstract fun accountViewModel(viewModel: AccountViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(EditProfileViewModel::class) + internal abstract fun editProfileViewModel(viewModel: EditProfileViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ConversationsViewModel::class) + internal abstract fun conversationsViewModel(viewModel: ConversationsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ListsViewModel::class) + internal abstract fun listsViewModel(viewModel: ListsViewModel): ViewModel + + + @Binds + @IntoMap + @ViewModelKey(AccountsInListViewModel::class) + internal abstract fun accountsInListViewModel(viewModel: AccountsInListViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ReportViewModel::class) + internal abstract fun reportViewModel(viewModel: ReportViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(SearchViewModel::class) + internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ComposeViewModel::class) + internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ScheduledTootViewModel::class) + internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ChatViewModel::class) + internal abstract fun chatViewModel(viewModel: ChatViewModel) : ViewModel + + @Binds + @IntoMap + @ViewModelKey(AnnouncementsViewModel::class) + internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(DraftsViewModel::class) + internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel + + //Add more ViewModels here +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt new file mode 100644 index 0000000..1810788 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt @@ -0,0 +1,22 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class AccessToken( + @SerializedName("access_token") val accessToken: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt new file mode 100644 index 0000000..d7540b8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -0,0 +1,105 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.text.Spanned +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class Account( + val id: String, + @SerializedName("username") val localUsername: String, + @SerializedName("acct") val username: String, + @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract + val note: Spanned, + val url: String, + val avatar: String, + val header: String, + val locked: Boolean = false, + @SerializedName("followers_count") val followersCount: Int = 0, + @SerializedName("following_count") val followingCount: Int = 0, + @SerializedName("statuses_count") val statusesCount: Int = 0, + val source: AccountSource? = null, + val bot: Boolean = false, + val emojis: List? = emptyList(), // nullable for backward compatibility + val fields: List? = emptyList(), //nullable for backward compatibility + val moved: Account? = null, + val pleroma: PleromaAccount? = null +) { + + val name: String + get() = if (displayName.isNullOrEmpty()) { + localUsername + } else displayName + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is Account) { + return false + } + return other.id == this.id + } + + fun deepEquals(other: Account): Boolean { + return id == other.id + && localUsername == other.localUsername + && displayName == other.displayName + && note == other.note + && url == other.url + && avatar == other.avatar + && header == other.header + && locked == other.locked + && followersCount == other.followersCount + && followingCount == other.followingCount + && statusesCount == other.statusesCount + && source == other.source + && bot == other.bot + && emojis == other.emojis + && fields == other.fields + && moved == other.moved + && pleroma == other.pleroma + } + + fun isRemote(): Boolean = this.username != this.localUsername +} + +data class AccountSource( + val privacy: Status.Visibility, + val sensitive: Boolean, + val note: String, + val fields: List? +) + +data class Field ( + val name: String, + val value: Spanned, + @SerializedName("verified_at") val verifiedAt: Date? +) + +data class StringField ( + val name: String, + val value: String +) + +data class PleromaAccount( + @SerializedName("ap_id") val apId: String? = null, + @SerializedName("accepts_chat_messages") val acceptsChatMessages: Boolean? = null, + @SerializedName("is_moderator") val isModerator: Boolean? = null, + @SerializedName("is_admin") val isAdmin: Boolean? = null +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt new file mode 100644 index 0000000..5cd32fe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -0,0 +1,57 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.text.Spanned +import com.google.gson.annotations.SerializedName +import java.util.* + +data class Announcement( + val id: String, + val content: Spanned, + @SerializedName("starts_at") val startsAt: Date?, + @SerializedName("ends_at") val endsAt: Date?, + @SerializedName("all_day") val allDay: Boolean, + @SerializedName("published_at") val publishedAt: Date, + @SerializedName("updated_at") val updatedAt: Date, + val read: Boolean, + val mentions: List, + val statuses: List, + val tags: List, + val emojis: List, + val reactions: List +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + val announcement = other as Announcement? + return id == announcement?.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + data class Reaction( + val name: String, + var count: Int, + var me: Boolean, + val url: String?, + @SerializedName("static_url") val staticUrl: String? + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt new file mode 100644 index 0000000..95a829c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt @@ -0,0 +1,23 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class AppCredentials( + @SerializedName("client_id") val clientId: String, + @SerializedName("client_secret") val clientSecret: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt new file mode 100644 index 0000000..587c763 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -0,0 +1,95 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.R +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class Attachment( + val id: String, + val url: String, + @SerializedName("preview_url") val previewUrl: String?, // can be null for e.g. audio attachments + val meta: MetaData?, + val type: Type, + val description: String?, + val blurhash: String? +) : Parcelable { + + @JsonAdapter(MediaTypeDeserializer::class) + enum class Type { + @SerializedName("image") + IMAGE, + @SerializedName("gifv") + GIFV, + @SerializedName("video") + VIDEO, + @SerializedName("audio") + AUDIO, + @SerializedName("unknown") + UNKNOWN + } + + class MediaTypeDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type { + return when (json.toString()) { + "\"image\"" -> Type.IMAGE + "\"gifv\"" -> Type.GIFV + "\"video\"" -> Type.VIDEO + "\"audio\"" -> Type.AUDIO + else -> Type.UNKNOWN + } + } + } + + fun describeAttachmentType() : Int { + return when(type) { + Type.IMAGE -> R.string.attachment_type_image + Type.VIDEO, Type.GIFV -> R.string.attachment_type_video + Type.AUDIO -> R.string.attachment_type_audio + Type.UNKNOWN -> R.string.attachment_type_unknown + } + } + + /** + * The meta data of an [Attachment]. + */ + @Parcelize + data class MetaData ( + val focus: Focus?, + val duration: Float? + ) : Parcelable + + /** + * The Focus entity, used to specify the focal point of an image. + * + * See here for more details what the x and y mean: + * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point + */ + @Parcelize + data class Focus ( + val x: Float, + val y: Float + ) : Parcelable +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt new file mode 100644 index 0000000..1b07cea --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -0,0 +1,45 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.text.Spanned +import com.google.gson.annotations.SerializedName + +data class Card( + val url: String, + val title: Spanned, + val description: Spanned, + @SerializedName("author_name") val authorName: String, + val image: String, + val type: String, + val width: Int, + val height: Int, + val blurhash: String? +) { + + override fun hashCode(): Int { + return url.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is Card) { + return false + } + val account = other as Card? + return account?.url == this.url + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt new file mode 100644 index 0000000..35c6ce1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt @@ -0,0 +1,44 @@ +/* Copyright 2020 Alibek Omarov + * + * This file is a part of Husky. + * + * 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. + * + * Husky 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 Husky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.text.Spanned +import com.google.gson.annotations.SerializedName +import java.util.* + +data class ChatMessage( + val id: String, + val content: Spanned?, + @SerializedName("chat_id") val chatId: String, + @SerializedName("account_id") val accountId: String, + @SerializedName("created_at") val createdAt: Date, + val attachment: Attachment?, + val emojis: List, + val card: Card? +) + +data class Chat( + val account: Account, + val id: String, + val unread: Long, + @SerializedName("last_message") val lastMessage: ChatMessage?, + @SerializedName("updated_at") val updatedAt: Date +) + +data class NewChatMessage( + val content: String, + val media_id: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt new file mode 100644 index 0000000..0e66385 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt @@ -0,0 +1,25 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class Conversation( + val id: String, + val accounts: List, + @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 + val unread: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt new file mode 100644 index 0000000..289a93f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -0,0 +1,34 @@ +/* Copyright 2019 Tusky contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class DeletedStatus( + var text: String?, + @SerializedName("in_reply_to_id") var inReplyToId: String?, + @SerializedName("spoiler_text") val spoilerText: String, + val visibility: Status.Visibility, + val sensitive: Boolean, + @SerializedName("media_attachments") var attachments: ArrayList?, + val poll: Poll?, + @SerializedName("created_at") val createdAt: Date +) { + fun isEmpty(): Boolean { + return text == null && attachments == null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt new file mode 100644 index 0000000..029b392 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -0,0 +1,36 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.os.Parcel +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class Emoji( + val shortcode: String, + val url: String, + @SerializedName("static_url") val staticUrl: String, + @SerializedName("visible_in_picker") val visibleInPicker: Boolean? +) : Parcelable + +data class EmojiReaction( + val name: String, + val count: Int, + val me: Boolean, + val accounts: List? // only for emoji_reactions_by +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt new file mode 100644 index 0000000..58bdc79 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -0,0 +1,48 @@ +/* Copyright 2018 Levi Bard + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class Filter ( + val id: String, + val phrase: String, + val context: List, + @SerializedName("expires_at") val expiresAt: String?, + val irreversible: Boolean, + @SerializedName("whole_word") val wholeWord: Boolean +) { + companion object { + const val HOME = "home" + const val NOTIFICATIONS = "notifications" + const val PUBLIC = "public" + const val THREAD = "thread" + const val ACCOUNT = "account" + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is Filter) { + return false + } + val filter = other as Filter? + return filter?.id.equals(id) + } +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt new file mode 100644 index 0000000..1eaaf68 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt @@ -0,0 +1,3 @@ +package com.keylesspalace.tusky.entity + +data class HashTag(val name: String) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt new file mode 100644 index 0000000..9473f03 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class IdentityProof( + val provider: String, + @SerializedName("provider_username") val username: String, + @SerializedName("profile_url") val profileUrl: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt new file mode 100644 index 0000000..f06d3ce --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -0,0 +1,70 @@ +/* Copyright 2018 Levi Bard + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class Instance ( + val uri: String, + val title: String, + val description: String, + val email: String, + val version: String, + val urls: Map, + val stats: Map?, + val thumbnail: String?, + val languages: List, + @SerializedName("contact_account") val contactAccount: Account, + @SerializedName("max_toot_chars") val maxTootChars: Int?, + @SerializedName("max_bio_chars") val maxBioChars: Int?, + @SerializedName("poll_limits") val pollLimits: PollLimits?, + @SerializedName("chat_limit") val chatLimit: Int?, + @SerializedName("avatar_upload_limit") val avatarUploadLimit: Long?, + @SerializedName("banner_upload_limit") val bannerUploadLimit: Long?, + @SerializedName("description_limit") val descriptionLimit: Int?, + @SerializedName("upload_limit") val uploadLimit: Long?, + val pleroma: InstancePleroma? +) { + override fun hashCode(): Int { + return uri.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is Instance) { + return false + } + val instance = other as Instance? + return instance?.uri.equals(uri) + } +} + +data class InstancePleroma ( + val metadata: InstancePleromaMetadata +) + +data class InstancePleromaMetadata ( + val features: List, + @SerializedName("fields_limits") val fieldsLimits: InstancePleromaMetadataFieldsLimits, +) + +data class InstancePleromaMetadataFieldsLimits( + @SerializedName("max_fields") val maxFields: Int, +) + +data class PollLimits ( + @SerializedName("max_options") val maxOptions: Int?, + @SerializedName("max_option_chars") val maxOptionChars: Int? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt new file mode 100644 index 0000000..16fd9e3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt @@ -0,0 +1,15 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.* + +/** + * API type for saving the scroll position of a timeline. + */ +data class Marker( + @SerializedName("last_read_id") + val lastReadId: String, + val version: Int, + @SerializedName("updated_at") + val updatedAt: Date +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt new file mode 100644 index 0000000..2f8eecf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -0,0 +1,26 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.entity + +/** + * Created by charlag on 1/4/18. + */ + +data class MastoList( + val id: String, + val title: String +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt new file mode 100644 index 0000000..5f64db1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -0,0 +1,40 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.android.parcel.Parcelize + +data class NewStatus( + val status: String, + @SerializedName("spoiler_text") val warningText: String, + @SerializedName("in_reply_to_id") val inReplyToId: String?, + val visibility: String, + val sensitive: Boolean, + @SerializedName("media_ids") val mediaIds: List?, + @SerializedName("scheduled_at") val scheduledAt: String?, + val poll: NewPoll?, + var content_type: String?, + val preview: Boolean? +) + +@Parcelize +data class NewPoll( + val options: List, + @SerializedName("expires_in") val expiresIn: Int, + val multiple: Boolean +): Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NodeInfo.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NodeInfo.kt new file mode 100644 index 0000000..05f858a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NodeInfo.kt @@ -0,0 +1,63 @@ +/* Copyright 2020 Alibek Omarov + * + * This file is a part of Husky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.* + +// .well-known/nodeinfo +data class NodeInfoLink( + val href: String, + val rel: String +) + +data class NodeInfoLinks( + val links: List +) + +// we care only about supported postFormats +// so implement only metadata fetching +data class NodeInfo( + val metadata: NodeInfoMetadata? = null, + val software: NodeInfoSoftware +) + +data class NodeInfoSoftware( + val name: String, + val version: String +) + +data class NodeInfoPleromaUploadLimits( + val avatar: Long?, + val background: Long?, + val banner: Long?, + val general: Long? +) + +data class NodeInfoPixelfedUploadLimits( + @SerializedName("max_photo_size") val maxPhotoSize: Long? +) + +data class NodeInfoPixelfedConfig( + val uploader: NodeInfoPixelfedUploadLimits? +) + +data class NodeInfoMetadata( + val postFormats: List?, + val uploadLimits: NodeInfoPleromaUploadLimits?, + val config: NodeInfoPixelfedConfig? +) + diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt new file mode 100644 index 0000000..c57addc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -0,0 +1,107 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.* +import com.google.gson.annotations.SerializedName +import com.google.gson.annotations.JsonAdapter +import java.util.* + +data class PleromaNotification( + @SerializedName("is_seen") val seen: Boolean +) + +data class Notification( + val type: Type, + val id: String, + val account: Account, + val status: Status?, + val pleroma: PleromaNotification? = null, + val emoji: String? = null, + @SerializedName("chat_message") val chatMessage: ChatMessage? = null, + @SerializedName("created_at") val createdAt: Date? = null, + val target: Account? = null) { + + @JsonAdapter(NotificationTypeAdapter::class) + enum class Type(val presentation: String) { + UNKNOWN("unknown"), + MENTION("mention"), + REBLOG("reblog"), + FAVOURITE("favourite"), + FOLLOW("follow"), + POLL("poll"), + EMOJI_REACTION("pleroma:emoji_reaction"), + FOLLOW_REQUEST("follow_request"), + CHAT_MESSAGE("pleroma:chat_mention"), + MOVE("move"), + STATUS("status"); /* Mastodon 3.3.0rc1 */ + + companion object { + + @JvmStatic + fun byString(s: String): Type { + values().forEach { + if (s == it.presentation) + return it + } + return UNKNOWN + } + val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, POLL, EMOJI_REACTION, FOLLOW_REQUEST, CHAT_MESSAGE, MOVE, STATUS) + + val asStringList = asList.map { it.presentation } + } + + override fun toString(): String { + return presentation + } + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is Notification) { + return false + } + val notification = other as Notification? + return notification?.id == this.id + } + + class NotificationTypeAdapter : JsonDeserializer { + + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type { + return Type.byString(json.asString) + } + + } + + companion object { + + // for Pleroma compatibility that uses Mention type + @JvmStatic + fun rewriteToStatusTypeIfNeeded(body: Notification, accountId: String) : Notification { + if (body.type == Type.MENTION + && body.status != null) { + return if (body.status.mentions.any { + it.id == accountId + }) body else body.copy(type = Type.STATUS) + } + return body + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt new file mode 100644 index 0000000..0d47c6f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt @@ -0,0 +1,47 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class Poll( + val id: String, + @SerializedName("expires_at") val expiresAt: Date?, + val expired: Boolean, + val multiple: Boolean, + @SerializedName("votes_count") val votesCount: Int, + @SerializedName("voters_count") val votersCount: Int?, // nullable for compatibility with Pleroma + val options: List, + val voted: Boolean +) { + + fun votedCopy(choices: List): Poll { + val newOptions = options.mapIndexed { index, option -> + if(choices.contains(index)) { + option.copy(votesCount = option.votesCount + 1) + } else { + option + } + } + + return copy( + options = newOptions, + votesCount = votesCount + choices.size, + votersCount = votersCount?.plus(1), + voted = true + ) + } + + fun toNewPoll(creationDate: Date) = NewPoll( + options.map { it.title }, + expiresAt?.let { + ((it.time - creationDate.time) / 1000).toInt() + 1 + }?: 3600, + multiple + ) + +} + +data class PollOption( + val title: String, + @SerializedName("votes_count") val votesCount: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt new file mode 100644 index 0000000..e25a3d1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -0,0 +1,33 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class Relationship ( + val id: String, + val following: Boolean, + @SerializedName("followed_by") val followedBy: Boolean, + val blocking: Boolean, + val muting: Boolean, + @SerializedName("muting_notifications") val mutingNotifications: Boolean, + val requested: Boolean, + @SerializedName("showing_reblogs") val showingReblogs: Boolean, + val subscribing: Boolean? = null, // Pleroma extension + @SerializedName("domain_blocking") val blockingDomain: Boolean, + val note: String?, // nullable for backward compatibility / feature detection + val notifying: Boolean? // since 3.3.0rc +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt new file mode 100644 index 0000000..2621bd5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt @@ -0,0 +1,25 @@ +/* Copyright 2019 kyori19 + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class ScheduledStatus( + val id: String, + @SerializedName("scheduled_at") val scheduledAt: String, + val params: StatusParams, + @SerializedName("media_attachments") val mediaAttachments: ArrayList +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt new file mode 100644 index 0000000..4307380 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt @@ -0,0 +1,22 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +data class SearchResult ( + val accounts: List, + val statuses: List, + val hashtags: List +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt new file mode 100644 index 0000000..47dc44d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -0,0 +1,214 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.URLSpan +import com.google.gson.annotations.SerializedName +import java.util.* + +data class Status( + var id: String, + var url: String?, // not present if it's reblog + val account: Account, + @SerializedName("in_reply_to_id") var inReplyToId: String?, + @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, + val reblog: Status?, + val content: Spanned, + @SerializedName("created_at") val createdAt: Date, + val emojis: List, + @SerializedName("reblogs_count") val reblogsCount: Int, + @SerializedName("favourites_count") val favouritesCount: Int, + var reblogged: Boolean, + var favourited: Boolean, + var bookmarked: Boolean, + var sensitive: Boolean, + @SerializedName("spoiler_text") val spoilerText: String, + val visibility: Visibility, + @SerializedName("media_attachments") var attachments: ArrayList, + val mentions: Array, + val application: Application?, + var pinned: Boolean?, + val poll: Poll?, + val card: Card?, + var content_type: String? = null, + val pleroma: PleromaStatus? = null, + var muted: Boolean = false /* set when either thread or user is muted */ +) { + + val actionableId: String + get() = reblog?.id ?: id + + val actionableStatus: Status + get() = reblog ?: this + + enum class Visibility(val num: Int) { + UNKNOWN(0), + @SerializedName("public") + PUBLIC(1), + @SerializedName("unlisted") + UNLISTED(2), + @SerializedName("private") + PRIVATE(3), + @SerializedName("direct") + DIRECT(4); + + fun serverString(): String { + return when (this) { + PUBLIC -> "public" + UNLISTED -> "unlisted" + PRIVATE -> "private" + DIRECT -> "direct" + UNKNOWN -> "unknown" + } + } + + companion object { + + @JvmStatic + fun byNum(num: Int): Visibility { + return when (num) { + 4 -> DIRECT + 3 -> PRIVATE + 2 -> UNLISTED + 1 -> PUBLIC + 0 -> UNKNOWN + else -> UNKNOWN + } + } + + @JvmStatic + fun byString(s: String): Visibility { + return when (s) { + "public" -> PUBLIC + "unlisted" -> UNLISTED + "private" -> PRIVATE + "direct" -> DIRECT + "unknown" -> UNKNOWN + else -> UNKNOWN + } + } + } + } + + fun rebloggingAllowed(): Boolean { + return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN) + } + + fun isPinned(): Boolean { + return pinned ?: false + } + + fun toDeletedStatus(): DeletedStatus { + return DeletedStatus( + text = getEditableText(), + inReplyToId = inReplyToId, + spoilerText = spoilerText, + visibility = visibility, + sensitive = sensitive, + attachments = attachments, + poll = poll, + createdAt = createdAt + ) + } + + fun isMuted(): Boolean { + return muted + } + + fun isUserMuted(): Boolean { + return muted && !isThreadMuted() + } + + fun isThreadMuted(): Boolean { + return pleroma?.threadMuted ?: false + } + + fun setThreadMuted(mute: Boolean) { + if(pleroma?.threadMuted != null) + pleroma.threadMuted = mute + } + + fun getConversationId(): Int { + return pleroma?.conversationId ?: -1 + } + + fun getEmojiReactions(): List? { + return pleroma?.emojiReactions; + } + + fun getInReplyToAccountAcct(): String? { + return pleroma?.inReplyToAccountAcct; + } + + fun getParentVisible(): Boolean { + return pleroma?.parentVisible ?: true; + } + + private fun getEditableText(): String { + val builder = SpannableStringBuilder(content) + for (span in content.getSpans(0, content.length, URLSpan::class.java)) { + val url = span.url + for ((_, url1, username) in mentions) { + if (url == url1) { + val start = builder.getSpanStart(span) + val end = builder.getSpanEnd(span) + builder.replace(start, end, "@$username") + break + } + } + } + return builder.toString() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + val status = other as Status? + return id == status?.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + data class PleromaStatus( + @SerializedName("thread_muted") var threadMuted: Boolean?, + @SerializedName("conversation_id") val conversationId: Int?, + @SerializedName("emoji_reactions") val emojiReactions: List?, + @SerializedName("in_reply_to_account_acct") val inReplyToAccountAcct: String?, + @SerializedName("parent_visible") val parentVisible: Boolean? + ) + + data class Mention ( + val id: String, + val url: String?, // can be null due to bug in some Pleroma versions + @SerializedName("acct") val username: String, + @SerializedName("username") val localUsername: String + ) + + data class Application ( + val name: String, + val website: String? + ) + + companion object { + const val MAX_MEDIA_ATTACHMENTS = 4 + const val MAX_POLL_OPTIONS = 4 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt new file mode 100644 index 0000000..1287619 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt @@ -0,0 +1,21 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +data class StatusContext ( + val ancestors: List, + val descendants: List +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt new file mode 100644 index 0000000..0e25e6c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt @@ -0,0 +1,26 @@ +/* Copyright 2019 kyori19 + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class StatusParams( + val text: String, + val sensitive: Boolean, + val visibility: Status.Visibility, + @SerializedName("spoiler_text") val spoilerText: String, + @SerializedName("in_reply_to_id") val inReplyToId: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Sticker.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Sticker.kt new file mode 100644 index 0000000..0f7746e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Sticker.kt @@ -0,0 +1,30 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +data class StickerPack( + val title: String, + val tabIcon: String, + val stickers: List, + var internal_url: String = "" +) : Comparable { + override fun compareTo(pack: StickerPack) : Int { + return title.compareTo(pack.title) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt new file mode 100644 index 0000000..ce761ed --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt @@ -0,0 +1,20 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class StreamEvent ( + val event: EventType, + val payload: String +) { + enum class EventType { + UNKNOWN, + @SerializedName("update") + UPDATE, + @SerializedName("notification") + NOTIFICATION, + @SerializedName("delete") + DELETE, + @SerializedName("filters_changed") + FILTERS_CHANGED; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt new file mode 100644 index 0000000..340a998 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -0,0 +1,417 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.AccountListActivity.Type +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.* +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.fragment_account_list.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException +import java.util.* +import javax.inject.Inject + +class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { + + @Inject + lateinit var api: MastodonApi + + private lateinit var type: Type + private var id: String? = null + private var emojiReaction: String? = null + + private lateinit var scrollListener: EndlessOnScrollListener + private lateinit var adapter: AccountAdapter + private var fetching = false + private var bottomId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + type = arguments?.getSerializable(ARG_TYPE) as Type + id = arguments?.getString(ARG_ID) + emojiReaction = arguments?.getString(ARG_EMOJI) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return inflater.inflate(R.layout.fragment_account_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + recyclerView.setHasFixedSize(true) + val layoutManager = LinearLayoutManager(view.context) + recyclerView.layoutManager = layoutManager + (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + + adapter = when (type) { + Type.BLOCKS -> BlocksAdapter(this) + Type.MUTES -> MutesAdapter(this) + Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this) + else -> FollowAdapter(this) + } + recyclerView.adapter = adapter + + scrollListener = object : EndlessOnScrollListener(layoutManager) { + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + if (bottomId == null) { + return + } + fetchAccounts(bottomId) + } + } + + recyclerView.addOnScrollListener(scrollListener) + + fetchAccounts() + } + + override fun onViewAccount(id: String) { + (activity as BaseActivity?)?.let { + val intent = AccountActivity.getIntent(it, id) + it.startActivityWithSlideInAnimation(intent) + } + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + if (!mute) { + api.unmuteAccount(id) + } else { + api.muteAccount(id, notifications) + } + .autoDispose(from(this)) + .subscribe({ + onMuteSuccess(mute, id, position, notifications) + }, { + onMuteFailure(mute, id, notifications) + }) + } + + private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) { + val mutesAdapter = adapter as MutesAdapter + if (muted) { + mutesAdapter.updateMutingNotifications(id, notifications, position) + return + } + val unmutedUser = mutesAdapter.removeItem(position) + + if (unmutedUser != null) { + Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + mutesAdapter.addItem(unmutedUser, position) + onMute(true, id, position, notifications) + } + .show() + } + } + + private fun onMuteFailure(mute: Boolean, accountId: String, notifications: Boolean) { + val verb = if (mute) { + if (notifications) { + "mute (notifications = true)" + } else { + "mute (notifications = false)" + } + } else { + "unmute" + } + Log.e(TAG, "Failed to $verb account id $accountId") + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + if (!block) { + api.unblockAccount(id) + } else { + api.blockAccount(id) + } + .autoDispose(from(this)) + .subscribe({ + onBlockSuccess(block, id, position) + }, { + onBlockFailure(block, id) + }) + } + + private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) { + if (blocked) { + return + } + val blocksAdapter = adapter as BlocksAdapter + val unblockedUser = blocksAdapter.removeItem(position) + + if (unblockedUser != null) { + Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + blocksAdapter.addItem(unblockedUser, position) + onBlock(true, id, position) + } + .show() + } + } + + private fun onBlockFailure(block: Boolean, accountId: String) { + val verb = if (block) { + "block" + } else { + "unblock" + } + Log.e(TAG, "Failed to $verb account accountId $accountId") + } + + override fun onRespondToFollowRequest(accept: Boolean, accountId: String, + position: Int) { + + val callback = object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + onRespondToFollowRequestSuccess(position) + } else { + onRespondToFollowRequestFailure(accept, accountId) + } + } + + override fun onFailure(call: Call, t: Throwable) { + onRespondToFollowRequestFailure(accept, accountId) + } + } + + val call = if (accept) { + api.authorizeFollowRequest(accountId) + } else { + api.rejectFollowRequest(accountId) + } + callList.add(call) + call.enqueue(callback) + } + + private fun onRespondToFollowRequestSuccess(position: Int) { + val followRequestsAdapter = adapter as FollowRequestsAdapter + followRequestsAdapter.removeItem(position) + } + + private fun onRespondToFollowRequestFailure(accept: Boolean, accountId: String) { + val verb = if (accept) { + "accept" + } else { + "reject" + } + Log.e(TAG, "Failed to $verb account id $accountId.") + } + + private fun getFetchCallByListType(fromId: String?): Single>> { + return when (type) { + Type.FOLLOWS -> { + val accountId = requireId(type, id) + api.accountFollowing(accountId, fromId) + } + Type.FOLLOWERS -> { + val accountId = requireId(type, id) + api.accountFollowers(accountId, fromId) + } + Type.BLOCKS -> api.blocks(fromId) + Type.MUTES -> api.mutes(fromId) + Type.FOLLOW_REQUESTS -> api.followRequests(fromId) + Type.REBLOGGED -> { + val statusId = requireId(type, id) + api.statusRebloggedBy(statusId, fromId) + } + Type.FAVOURITED -> { + val statusId = requireId(type, id) + api.statusFavouritedBy(statusId, fromId) + } + Type.REACTED -> { + // HACKHACK: make compiler happy + val statusId = requireId(type, id) + api.statusFavouritedBy(statusId, fromId) + } + } + } + + private fun requireId(type: Type, id: String?, name: String = "id"): String { + return requireNotNull(id) { name+" must not be null for type "+type.name } + } + + private fun getEmojiReactionFetchCall(): Single>> { + val statusId = requireId(type, id) + val emoji = requireId(type, emojiReaction, "emoji") + return api.statusReactedBy(statusId, emoji) + } + + private fun fetchAccounts(fromId: String? = null) { + if (fetching) { + return + } + fetching = true + + if (fromId != null) { + recyclerView.post { adapter.setBottomLoading(true) } + } + + if(type == Type.REACTED) { + getEmojiReactionFetchCall() + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ response -> + val emojiReaction = response.body() + + if (response.isSuccessful && emojiReaction != null && emojiReaction.size > 0 && emojiReaction.get(0).accounts != null) { + val linkHeader = response.headers()["Link"] + onFetchAccountsSuccess(emojiReaction.get(0).accounts!!, linkHeader) + } else { + onFetchAccountsFailure(Exception(response.message())) + } + }, {throwable -> + onFetchAccountsFailure(throwable) + }) + } else { + getFetchCallByListType(fromId) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ response -> + val accountList = response.body() + + if (response.isSuccessful && accountList != null) { + val linkHeader = response.headers()["Link"] + onFetchAccountsSuccess(accountList, linkHeader) + } else { + onFetchAccountsFailure(Exception(response.message())) + } + }, {throwable -> + onFetchAccountsFailure(throwable) + }) + } + } + + private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { + adapter.setBottomLoading(false) + + val links = HttpHeaderLink.parse(linkHeader) + val next = HttpHeaderLink.findByRelationType(links, "next") + val fromId = next?.uri?.getQueryParameter("max_id") + + if (adapter.itemCount > 0) { + adapter.addItems(accounts) + } else { + adapter.update(accounts) + } + + if (adapter is MutesAdapter) { + fetchRelationships(accounts.map { it.id }) + } + + bottomId = fromId + + fetching = false + + if (adapter.itemCount == 0) { + messageView.show() + messageView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) + } else { + messageView.hide() + } + } + + private fun fetchRelationships(ids: List) { + api.relationships(ids) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe(::onFetchRelationshipsSuccess) { + onFetchRelationshipsFailure(ids) + } + } + + private fun onFetchRelationshipsSuccess(relationships: List) { + val mutesAdapter = adapter as MutesAdapter + var mutingNotificationsMap = HashMap() + relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) } + mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) + } + + private fun onFetchRelationshipsFailure(ids: List) { + Log.e(TAG, "Fetch failure for relationships of accounts: $ids") + } + + private fun onFetchAccountsFailure(throwable: Throwable) { + fetching = false + Log.e(TAG, "Fetch failure", throwable) + + if (adapter.itemCount == 0) { + messageView.show() + if (throwable is IOException) { + messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + messageView.hide() + this.fetchAccounts(null) + } + } else { + messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + messageView.hide() + this.fetchAccounts(null) + } + } + } + } + + companion object { + private const val TAG = "AccountList" // logging tag + private const val ARG_TYPE = "type" + private const val ARG_ID = "id" + private const val ARG_EMOJI = "emoji" + + fun newInstance(type: Type, id: String? = null, emoji: String? = null): AccountListFragment { + return AccountListFragment().apply { + arguments = Bundle(3).apply { + putSerializable(ARG_TYPE, type) + putString(ARG_ID, id) + putString(ARG_EMOJI, emoji) + } + } + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt new file mode 100644 index 0000000..5e9ed0e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -0,0 +1,356 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment + +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.view.SquareImageView +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import kotlinx.android.synthetic.main.fragment_timeline.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException +import java.util.* +import javax.inject.Inject + +/** + * Created by charlag on 26/10/2017. + * + * Fragment with multiple columns of media previews for the specified account. + */ + +class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { + companion object { + @JvmStatic + fun newInstance(accountId: String, enableSwipeToRefresh:Boolean=true): AccountMediaFragment { + val fragment = AccountMediaFragment() + val args = Bundle() + args.putString(ACCOUNT_ID_ARG, accountId) + args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,enableSwipeToRefresh) + fragment.arguments = args + return fragment + } + + private const val ACCOUNT_ID_ARG = "account_id" + private const val TAG = "AccountMediaFragment" + private const val ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh" + } + + private var isSwipeToRefreshEnabled: Boolean = true + private var needToRefresh = false + private var filterMuted = false + + @Inject + lateinit var api: MastodonApi + + private val adapter = MediaGridAdapter() + private var currentCall: Call>? = null + private val statuses = mutableListOf() + private var fetchingStatus = FetchingStatus.NOT_FETCHING + + private lateinit var accountId: String + + private val callback = object : Callback> { + override fun onFailure(call: Call>?, t: Throwable?) { + fetchingStatus = FetchingStatus.NOT_FETCHING + + if (isAdded) { + swipeRefreshLayout.isRefreshing = false + progressBar.visibility = View.GONE + topProgressBar?.hide() + statusView.show() + if (t is IOException) { + statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + doInitialLoadingIfNeeded() + } + } else { + statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + doInitialLoadingIfNeeded() + } + } + } + + Log.d(TAG, "Failed to fetch account media", t) + } + + override fun onResponse(call: Call>, response: Response>) { + fetchingStatus = FetchingStatus.NOT_FETCHING + if (isAdded) { + swipeRefreshLayout.isRefreshing = false + progressBar.visibility = View.GONE + topProgressBar?.hide() + + val body = response.body() + body?.let { fetched -> + // filter muted statuses if needed + val filtered = fetched.filter { !(filterMuted && it.muted) } + statuses.addAll(0, filtered) + // flatMap requires iterable but I don't want to box each array into list + val result = mutableListOf() + for (status in filtered) { + result.addAll(AttachmentViewData.list(status)) + } + adapter.addTop(result) + if (result.isNotEmpty()) + recyclerView.scrollToPosition(0) + + if (statuses.isEmpty()) { + statusView.show() + statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, + null) + } + } + } + } + } + + private val bottomCallback = object : Callback> { + override fun onFailure(call: Call>?, t: Throwable?) { + fetchingStatus = FetchingStatus.NOT_FETCHING + + Log.d(TAG, "Failed to fetch account media", t) + } + + override fun onResponse(call: Call>, response: Response>) { + fetchingStatus = FetchingStatus.NOT_FETCHING + val body = response.body() + body?.let { fetched -> + Log.d(TAG, "fetched ${fetched.size} statuses") + if (fetched.isNotEmpty()) Log.d(TAG, "first: ${fetched.first().id}, last: ${fetched.last().id}") + + // filter muted statuses if needed + val filtered = fetched.filter { !(filterMuted && it.muted) } + + statuses.addAll(filtered) + Log.d(TAG, "now there are ${statuses.size} statuses") + // flatMap requires iterable but I don't want to box each array into list + val result = mutableListOf() + for (status in filtered) { + result.addAll(AttachmentViewData.list(status)) + } + adapter.addBottom(result) + } + } + + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,true) == true + accountId = arguments?.getString(ACCOUNT_ID_ARG)!! + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + + val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) + val layoutManager = GridLayoutManager(view.context, columnCount) + + adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground) + + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + + if (isSwipeToRefreshEnabled) { + swipeRefreshLayout.setOnRefreshListener { + refresh() + } + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + statusView.visibility = View.GONE + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + + override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) { + if (dy > 0) { + val itemCount = layoutManager.itemCount + val lastItem = layoutManager.findLastCompletelyVisibleItemPosition() + if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { + statuses.lastOrNull()?.let { last -> + Log.d(TAG, "Requesting statuses with max_id: ${last.id}, (bottom)") + fetchingStatus = FetchingStatus.FETCHING_BOTTOM + currentCall = api.accountStatuses(accountId, last.id, null, null, null, true, null) + currentCall?.enqueue(bottomCallback) + } + } + } + } + }) + + filterMuted = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean( + PrefKeys.HIDE_MUTED_USERS, false + ) + + doInitialLoadingIfNeeded() + } + + private fun refresh() { + statusView.hide() + if (fetchingStatus != FetchingStatus.NOT_FETCHING) return + currentCall = if (statuses.isEmpty()) { + fetchingStatus = FetchingStatus.INITIAL_FETCHING + api.accountStatuses(accountId, null, null, null, null, true, null) + } else { + fetchingStatus = FetchingStatus.REFRESHING + api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null) + } + currentCall?.enqueue(callback) + + if (!isSwipeToRefreshEnabled) + topProgressBar?.show() + } + + private fun doInitialLoadingIfNeeded() { + if (isAdded) { + statusView.hide() + } + if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { + fetchingStatus = FetchingStatus.INITIAL_FETCHING + currentCall = api.accountStatuses(accountId, null, null, null, null, true, null) + currentCall?.enqueue(callback) + } + else if (needToRefresh) + refresh() + needToRefresh = false + } + + private fun viewMedia(items: List, currentIndex: Int, view: View?) { + + when (items[currentIndex].attachment.type) { + Attachment.Type.IMAGE, + Attachment.Type.GIFV, + Attachment.Type.VIDEO, + Attachment.Type.AUDIO -> { + val intent = ViewMediaActivity.newIntent(context, items, currentIndex) + if (view != null && activity != null) { + val url = items[currentIndex].attachment.url + ViewCompat.setTransitionName(view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity!!, view, url) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Attachment.Type.UNKNOWN -> { + LinkHelper.openLink(items[currentIndex].attachment.url, context) + } + } + } + + private enum class FetchingStatus { + NOT_FETCHING, INITIAL_FETCHING, FETCHING_BOTTOM, REFRESHING + } + + inner class MediaGridAdapter : + RecyclerView.Adapter() { + + var baseItemColor = Color.BLACK + + private val items = mutableListOf() + private val itemBgBaseHSV = FloatArray(3) + private val random = Random() + + fun addTop(newItems: List) { + items.addAll(0, newItems) + notifyItemRangeInserted(0, newItems.size) + } + + fun addBottom(newItems: List) { + if (newItems.isEmpty()) return + + val oldLen = items.size + items.addAll(newItems) + notifyItemRangeInserted(oldLen, newItems.size) + } + + override fun onAttachedToRecyclerView(recycler_view: RecyclerView) { + val hsv = FloatArray(3) + Color.colorToHSV(baseItemColor, hsv) + super.onAttachedToRecyclerView(recycler_view) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { + val view = SquareImageView(parent.context) + view.scaleType = ImageView.ScaleType.CENTER_CROP + return MediaViewHolder(view) + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { + itemBgBaseHSV[2] = random.nextFloat() * (1f - 0.3f) + 0.3f + holder.imageView.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) + val item = items[position] + + Glide.with(holder.imageView) + .load(item.attachment.previewUrl) + .centerInside() + .into(holder.imageView) + } + + + inner class MediaViewHolder(val imageView: ImageView) + : RecyclerView.ViewHolder(imageView), + View.OnClickListener { + init { + itemView.setOnClickListener(this) + } + + // saving some allocations + override fun onClick(v: View?) { + viewMedia(items, adapterPosition, imageView) + } + } + } + + override fun refreshContent() { + if (isAdded) + refresh() + else + needToRefresh = true + } + + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/BaseFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/BaseFragment.java new file mode 100644 index 0000000..b674b8b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/BaseFragment.java @@ -0,0 +1,43 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import java.util.ArrayList; +import java.util.List; + +import retrofit2.Call; + +public class BaseFragment extends Fragment { + protected List callList; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + callList = new ArrayList<>(); + } + + @Override + public void onDestroy() { + for (Call call : callList) { + call.cancel(); + } + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt new file mode 100644 index 0000000..a51cbad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt @@ -0,0 +1,781 @@ +package com.keylesspalace.tusky.fragment + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import androidx.arch.core.util.Function +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.* +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.PostLookupFallbackBehavior +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.ChatsAdapter +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.adapter.TimelineAdapter +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.chat.ChatActivity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Chat +import com.keylesspalace.tusky.entity.ChatMessage +import com.keylesspalace.tusky.entity.NewChatMessage +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.ChatActionListener +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.repository.* +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.Either.Left +import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.keylesspalace.tusky.viewdata.ChatViewData +import com.uber.autodispose.AutoDispose +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider +import com.uber.autodispose.android.lifecycle.autoDispose +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.fragment_timeline.* +import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, ReselectableFragment, ChatActionListener, OnRefreshListener { + private val TAG = "ChatsF" // logging tag + private val LOAD_AT_ONCE = 30 + private val BROKEN_PAGINATION_IN_BACKEND = true // break pagination until it's not fixed in plemora + + + @Inject + lateinit var eventHub: EventHub + @Inject + lateinit var api: MastodonApi + @Inject + lateinit var accountManager: AccountManager + @Inject + lateinit var chatRepo: ChatRepository + @Inject + lateinit var timelineCases: TimelineCases + + lateinit var adapter: ChatsAdapter + + lateinit var layoutManager: LinearLayoutManager + + private lateinit var scrollListener: EndlessOnScrollListener + + private lateinit var bottomSheetActivity: BottomSheetActivity + private var hideFab = false + private var bottomLoading = false + + private var eventRegistered = false + private var isSwipeToRefreshEnabled = true + private var isNeedRefresh = false + private var didLoadEverythingBottom = false + private var initialUpdateFailed = false + + private enum class FetchEnd { + TOP, BOTTOM, MIDDLE + } + + private val chats = PairedList(Function {input -> + input.asRightOrNull()?.let(ViewDataUtils::chatToViewData) ?: + ChatViewData.Placeholder(input.asLeft().id, false) + }) + + private val listUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + if (isAdded) { + Log.d(TAG, "onInserted"); + adapter.notifyItemRangeInserted(position, count) + // scroll up when new items at the top are loaded while being in the first position + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.itemCount != count) { + if (isSwipeToRefreshEnabled) + recyclerView.scrollBy(0, Utils.dpToPx(context!!, -30)); + else + recyclerView.scrollToPosition(0); + } + } + } + + override fun onRemoved(position: Int, count: Int) { + Log.d(TAG, "onRemoved"); + adapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + Log.d(TAG, "onMoved"); + adapter.notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + Log.d(TAG, "onChanged"); + adapter.notifyItemRangeChanged(position, count, payload) + } + } + + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean { + return oldItem.getViewDataId() == newItem.getViewDataId() + } + + override fun areContentsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload(oldItem: ChatViewData, newItem: ChatViewData): Any? { + return if (oldItem.deepEquals(newItem)) { + //If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else // If items are different - update a whole view holder + null + } + } + + private val differ = AsyncListDiffer(listUpdateCallback, + AsyncDifferConfig.Builder(diffCallback).build()) + + private val dataSource = object : TimelineAdapter.AdapterDataSource { + override fun getItemCount(): Int { + return differ.currentList.size + } + + override fun getItemAt(pos: Int): ChatViewData { + return differ.currentList[pos] + } + } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = false, + cardViewMode = CardViewMode.NONE, + confirmReblogs = false, + renderStatusAsMention = false, + hideStats = false + ) + + adapter = ChatsAdapter(dataSource, statusDisplayOptions, this, accountManager.activeAccount!!.accountId) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + bottomSheetActivity = if (context is BottomSheetActivity) { + context + } else { + throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + swipeRefreshLayout.setOnRefreshListener(this) + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + // TODO: a11y + recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(view.context) + recyclerView.layoutManager = layoutManager + recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + recyclerView.adapter = adapter + + if (chats.isEmpty()) { + progressBar.visibility = View.VISIBLE + bottomLoading = true + sendInitialRequest() + } else { + progressBar.visibility = View.GONE + if (isNeedRefresh) onRefresh() + } + } + private fun sendInitialRequest() { + // debug + // sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1) + tryCache() + } + + private fun tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + chatRepo.getChats(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { newChats -> + if (newChats.size > 1) { + val mutableChats = newChats.toMutableList() + mutableChats.removeAll { it.isLeft() } + + chats.clear() + chats.addAll(mutableChats) + + updateAdapter() + progressBar.visibility = View.GONE + } + updateCurrent() + loadAbove() + } + } + + private fun updateCurrent() { + if (!BROKEN_PAGINATION_IN_BACKEND && chats.isEmpty()) { + return + } + + val topId = chats.firstOrNull { it.isRight() }?.asRight()?.id + chatRepo.getChats(topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe({ newChats -> + initialUpdateFailed = false + // When cached timeline is too old, we would replace it with nothing + if (newChats.isNotEmpty()) { + // clear old cached statuses + if(BROKEN_PAGINATION_IN_BACKEND) { + chats.clear() + } else { + chats.removeAll { + if(it.isLeft()) { + val p = it.asLeft() + p.id.length < topId!!.length || p.id < topId + } else { + val c = it.asRight() + c.id.length < topId!!.length || c.id < topId + } + } + } + chats.addAll(newChats) + updateAdapter() + } + bottomLoading = false + // Indicate that we are not loading anymore + progressBar.visibility = View.GONE + swipeRefreshLayout.isRefreshing = false + }, { + initialUpdateFailed = true + // Indicate that we are not loading anymore + progressBar.visibility = View.GONE + swipeRefreshLayout.isRefreshing = false + }) + } + + private fun showNothing() { + statusView.visibility = View.VISIBLE + statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + + private fun removeAllByAccountId(accountId: String) { + chats.removeAll { + val chat = it.asRightOrNull() + chat != null && chat.account.id == accountId + } + updateAdapter() + } + + private fun removeAllByInstance(instance: String) { + chats.removeAll { + val chat = it.asRightOrNull() + chat != null && LinkHelper.getDomain(chat.account.url) == instance + } + updateAdapter() + } + + private fun deleteChatById(id: String) { + val iterator = chats.iterator() + while(iterator.hasNext()) { + val chat = iterator.next().asRightOrNull() + if(chat != null && chat.id == id) { + iterator.remove() + updateAdapter() + break + } + } + + if(chats.isEmpty()) { + showNothing() + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't + * guaranteed to be set until then. */ + /* Use a modified scroll listener that both loads more statuses as it goes, and hides + * the follow button on down-scroll. */ + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + hideFab = preferences.getBoolean("fabHide", false) + scrollListener = object : EndlessOnScrollListener(layoutManager) { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(view, dx, dy) + val activity = activity as ActionButtonActivity? + val composeButton = activity!!.actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + if(!BROKEN_PAGINATION_IN_BACKEND) + this@ChatsFragment.onLoadMore() + } + } + recyclerView.addOnScrollListener(scrollListener) + if (!eventRegistered) { + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event: Event? -> + when(event) { + is BlockEvent -> removeAllByAccountId(event.accountId) + is MuteEvent -> removeAllByAccountId(event.accountId) + is DomainMuteEvent -> removeAllByInstance(event.instance) + is StatusDeletedEvent -> deleteChatById(event.statusId) + is PreferenceChangedEvent -> onPreferenceChanged(event.preferenceKey) + is ChatMessageReceivedEvent -> onRefresh() // TODO: proper update + } + } + eventRegistered = true + } + } + + /* + private fun onChatMessageReceived(msg: ChatMessage) { + val pos = findChatPosition(msg.chatId) + if(pos == -1) { + + return + } + + val oldChat = chats[pos].asRight() + val newChat = Chat(oldChat.account, oldChat.id, oldChat.unread + 1, msg, msg.createdAt) + val newViewData = ViewDataUtils.chatToViewData(newChat) + + chats.removeAt(pos) + chats.add(pos, newChat.lift()) + chats.sortByDescending { + if(it.isLeft()) Date(Long.MIN_VALUE) + else it.asRight().updatedAt + } + + updateAdapter() + } + */ + + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + when (key) { + "fabHide" -> { + hideFab = sharedPreferences.getBoolean("fabHide", false) + } + } + } + + override fun onRefresh() { + if (isSwipeToRefreshEnabled) + swipeRefreshLayout.isEnabled = true + + statusView.visibility = View.GONE + isNeedRefresh = false + + if (this.initialUpdateFailed) { + updateCurrent() + } + loadAbove() + } + + private fun loadAbove() { + if(BROKEN_PAGINATION_IN_BACKEND) { + updateCurrent() + return + } + + var firstOrNull: String? = null + var secondOrNull: String? = null + for (i in chats.indices) { + val chat = chats[i] + if (chat.isRight()) { + firstOrNull = chat.asRight().id + if (i + 1 < chats.size && chats[i + 1].isRight()) { + secondOrNull = chats[i + 1].asRight().id + } + break + } + } + if (firstOrNull != null) { + sendFetchChatsRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) + } else { + sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1) + } + } + + private fun onLoadMore() { + if (BROKEN_PAGINATION_IN_BACKEND) + updateCurrent() + return + + if (didLoadEverythingBottom || bottomLoading) { + return + } + if (chats.isEmpty()) { + sendInitialRequest() + return + } + bottomLoading = true + val last = chats.last() + val placeholder: Placeholder + if (last.isRight()) { + val placeholderId = last.asRight().id.dec() + placeholder = Placeholder(placeholderId) + chats.add(Left(placeholder)) + } else { + placeholder = last.asLeft() + } + chats.setPairedItem(chats.size - 1, + ChatViewData.Placeholder(placeholder.id, true)) + updateAdapter() + val bottomId = chats.findLast { it.isRight() }?.let { it.asRight().id } + sendFetchChatsRequest(bottomId, null, null, FetchEnd.BOTTOM, -1) + } + + + private fun sendFetchChatsRequest(maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, + fetchEnd: FetchEnd, pos: Int) { + if (isAdded + && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && progressBar.visibility != View.VISIBLE) + && !isSwipeToRefreshEnabled) + topProgressBar.show() + // allow getting old statuses/fallbacks for network only for for bottom loading + val mode = if (fetchEnd == FetchEnd.BOTTOM) { + TimelineRequestMode.ANY + } else { + TimelineRequestMode.NETWORK + } + chatRepo.getChats(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( { result -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) }, + { onFetchTimelineFailure(Exception(it), fetchEnd, pos) }) + } + + private fun updateChats(newChats: MutableList, fullFetch: Boolean) { + if (newChats.isEmpty()) { + updateAdapter() + return + } + if (chats.isEmpty()) { + chats.addAll(newChats) + } else { + val lastOfNew = newChats[newChats.size - 1] + val index = chats.indexOf(lastOfNew) + if (index >= 0) { + chats.subList(0, index).clear() + } + val newIndex = newChats.indexOf(chats[0]) + if (newIndex == -1) { + if (index == -1 && fullFetch) { + newChats.last { it.isRight() }.let { + val placeholderId = it.asRight().id.inc() + newChats.add(Left(Placeholder(placeholderId))) + } + } + chats.addAll(0, newChats) + } else { + chats.addAll(0, newChats.subList(0, newIndex)) + } + } + // Remove all consecutive placeholders + removeConsecutivePlaceholders() + updateAdapter() + } + + private fun removeConsecutivePlaceholders() { + for (i in 0 until chats.size - 1) { + if (chats[i].isLeft() && chats[i + 1].isLeft()) { + chats.removeAt(i) + } + } + } + + private fun replacePlaceholderWithChats(newChats: MutableList, + fullFetch: Boolean, pos: Int) { + val placeholder = chats[pos] + if (placeholder.isLeft()) { + chats.removeAt(pos) + } + if (newChats.isEmpty()) { + updateAdapter() + return + } + if (fullFetch) { + newChats.add(placeholder) + } + chats.addAll(pos, newChats) + removeConsecutivePlaceholders() + updateAdapter() + } + + private fun addItems(newChats: List) { + if (newChats.isEmpty()) { + return + } + val last = chats.findLast { it.isRight() } + + // I was about to replace findStatus with indexOf but it is incorrect to compare value + // types by ID anyway and we should change equals() for Status, I think, so this makes sense + if (last != null && !newChats.contains(last)) { + chats.addAll(newChats) + removeConsecutivePlaceholders() + updateAdapter() + } + } + + private fun onFetchTimelineSuccess(chats: MutableList, + fetchEnd: FetchEnd, pos: Int) { + + // We filled the hole (or reached the end) if the server returned less statuses than we + // we asked for. + val fullFetch = chats.size >= LOAD_AT_ONCE + + when (fetchEnd) { + FetchEnd.TOP -> { + updateChats(chats, fullFetch) + } + FetchEnd.MIDDLE -> { + replacePlaceholderWithChats(chats, fullFetch, pos) + } + FetchEnd.BOTTOM -> { + if (this.chats.isNotEmpty() && !this.chats.last().isRight()) { + this.chats.removeAt(this.chats.size - 1) + updateAdapter() + } + + if (chats.isNotEmpty() && !chats.last().isRight()) { + // Removing placeholder if it's the last one from the cache + chats.removeAt(chats.size - 1) + } + + val oldSize = this.chats.size + if (this.chats.size > 1) { + addItems(chats) + } else { + updateChats(chats, fullFetch) + } + + if (this.chats.size == oldSize) { + // This may be a brittle check but seems like it works + // Can we check it using headers somehow? Do all server support them? + didLoadEverythingBottom = true + } + } + } + if (isAdded) { + topProgressBar.hide() + updateBottomLoadingState(fetchEnd) + progressBar.visibility = View.GONE + swipeRefreshLayout.isRefreshing = false + swipeRefreshLayout.isEnabled = true + if (this.chats.size == 0) { + showNothing() + } else { + this.statusView.visibility = View.GONE + } + } + } + + private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) { + if (isAdded) { + swipeRefreshLayout.isRefreshing = false + topProgressBar.hide() + if (fetchEnd == FetchEnd.MIDDLE && !chats[position].isRight()) { + var placeholder = chats[position].asLeftOrNull() + val newViewData: ChatViewData + if (placeholder == null) { + val chat = chats[position - 1].asRight() + val newId = chat.id.dec() + placeholder = Placeholder(newId) + } + newViewData = ChatViewData.Placeholder(placeholder.id, false) + chats.setPairedItem(position, newViewData) + updateAdapter() + } else if (chats.isEmpty()) { + swipeRefreshLayout.isEnabled = false + statusView.visibility = View.VISIBLE + if (exception is IOException) { + statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + progressBar.visibility = View.VISIBLE + onRefresh() + } + } else { + statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + progressBar.visibility = View.VISIBLE + onRefresh() + } + } + } + Log.e(TAG, "Fetch Failure: " + exception.message) + updateBottomLoadingState(fetchEnd) + progressBar.visibility = View.GONE + } + } + + private fun updateBottomLoadingState(fetchEnd: FetchEnd) { + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false + } + } + + override fun onLoadMore(position: Int) { + //check bounds before accessing list, + if (chats.size >= position && position > 0) { + val fromChat = chats[position - 1].asRightOrNull() + val toChat = chats[position + 1].asRightOrNull() + if (fromChat == null || toChat == null) { + Log.e(TAG, "Failed to load more at $position, wrong placeholder position") + return + } + + val maxMinusOne = if (chats.size > position + 1 && chats[position + 2].isRight()) chats[position + 1].asRight().id else null + sendFetchChatsRequest(fromChat.id, toChat.id, maxMinusOne, + FetchEnd.MIDDLE, position) + + val (id) = chats[position].asLeft() + val newViewData = ChatViewData.Placeholder(id, true) + chats.setPairedItem(position, newViewData) + updateAdapter() + } else { + Log.e(TAG, "error loading more") + } + } + + override fun onViewAccount(id: String?) { + id?.let(bottomSheetActivity::viewAccount) + } + + override fun onViewUrl(url: String?) { + url?.let { bottomSheetActivity.viewUrl(it, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } + } + + // never called + override fun onViewTag(tag: String?) {} + + private fun updateAdapter() { + Log.d(TAG, "updateAdapter") + differ.submitList(chats.pairedCopy) + } + + private fun jumpToTop() { + if (isAdded) { + layoutManager.scrollToPosition(0) + recyclerView.stopScroll() + scrollListener.reset() + } + } + + override fun onReselect() { + jumpToTop() + } + + override fun onResume() { + super.onResume() + startUpdateTimestamp() + } + + override fun refreshContent() { + if (isAdded) onRefresh() else isNeedRefresh = true + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private fun startUpdateTimestamp() { + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_PAUSE) + .subscribe { updateAdapter() } + } + } + + private fun findChatPosition(id: String) : Int { + return chats.indexOfFirst { it.isRight() && it.asRight().id == id } + } + + private fun markAsRead(chat: Chat) { + val pos = findChatPosition(chat.id) + val chatViewData = ViewDataUtils.chatToViewData(chat) + + chats.setPairedItem(pos, chatViewData) + updateAdapter() + } + + override fun onMore(id: String, v: View) { + val popup = PopupMenu(requireContext(), v) + popup.inflate(R.menu.chat_more) + val pos = findChatPosition(id) + val chat = chats[pos].asRight() + // val menu = popup.menu + popup.setOnMenuItemClickListener { + when(it.itemId) { + R.id.chat_mark_as_read -> { + api.markChatAsRead(chat.id, chat.lastMessage?.id ?: null) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe({ chat -> markAsRead(chat) + }, { err -> Log.e(TAG, "Failed to mark chat as read", err) }) + + true + } + else -> { + false // ???? + } + } + } + popup.show() + } + + override fun openChat(position: Int) { + if(position < 0 || position >= chats.size) + return + + val chat = chats[position].asRightOrNull() + chat?.let { + bottomSheetActivity.openChat(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java new file mode 100644 index 0000000..fb765cb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -0,0 +1,1484 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.PopupWindow; +import android.widget.ProgressBar; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.arch.core.util.Function; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.util.Pair; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.NotificationsAdapter; +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; +import com.keylesspalace.tusky.appstore.*; +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.entity.*; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Poll; +import com.keylesspalace.tusky.entity.Relationship; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.ActionButtonActivity; +import com.keylesspalace.tusky.interfaces.ReselectableFragment; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.settings.PrefKeys; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.Either; +import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; +import com.keylesspalace.tusky.util.ListUtils; +import com.keylesspalace.tusky.util.NotificationTypeConverterKt; +import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.ViewDataUtils; +import com.keylesspalace.tusky.view.BackgroundMessageView; +import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import at.connyduck.sparkbutton.helpers.Utils; +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import kotlin.Unit; +import kotlin.collections.CollectionsKt; +import kotlin.jvm.functions.Function1; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static com.keylesspalace.tusky.util.StringUtils.isLessThan; +import static com.uber.autodispose.AutoDispose.autoDisposable; +import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; + +public class NotificationsFragment extends SFragment implements + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + NotificationsAdapter.NotificationActionListener, + AccountActionListener, + Injectable, ReselectableFragment { + private static final String TAG = "NotificationF"; // logging tag + + private static final int LOAD_AT_ONCE = 30; + private int maxPlaceholderId = 0; + + + private Set notificationFilter = new HashSet<>(); + + private enum FetchEnd { + TOP, + BOTTOM, + MIDDLE + } + + /** + * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor + * and reuse in different places as needed. + */ + private static final class Placeholder { + final long id; + + public static Placeholder getInstance(long id) { + return new Placeholder(id); + } + + private Placeholder(long id) { + this.id = id; + } + } + + @Inject + AccountManager accountManager; + @Inject + EventHub eventHub; + + private SwipeRefreshLayout swipeRefreshLayout; + private RecyclerView recyclerView; + private ProgressBar progressBar; + private BackgroundMessageView statusView; + private AppBarLayout appBarOptions; + + private LinearLayoutManager layoutManager; + private EndlessOnScrollListener scrollListener; + private NotificationsAdapter adapter; + private Button buttonFilter; + private boolean hideFab; + private boolean topLoading; + private boolean bottomLoading; + private String bottomId; + private boolean alwaysShowSensitiveMedia; + private boolean alwaysOpenSpoiler; + private boolean showNotificationsFilter; + private boolean showingError; + private boolean withMuted; + + // Each element is either a Notification for loading data or a Placeholder + private final PairedList, NotificationViewData> notifications + = new PairedList<>(new Function, NotificationViewData>() { + @Override + public NotificationViewData apply(Either input) { + if (input.isRight()) { + Notification notification = Notification.rewriteToStatusTypeIfNeeded( + input.asRight(), accountManager.getActiveAccount().getAccountId() + ); + + return ViewDataUtils.notificationToViewData( + notification, + alwaysShowSensitiveMedia, + alwaysOpenSpoiler + ); + } else { + return new NotificationViewData.Placeholder(input.asLeft().id, false); + } + } + }); + + public static NotificationsFragment newInstance() { + NotificationsFragment fragment = new NotificationsFragment(); + Bundle arguments = new Bundle(); + fragment.setArguments(arguments); + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_timeline_notifications, container, false); + + @NonNull Context context = inflater.getContext(); // from inflater to silence warning + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); + //Clear notifications on filter visibility change to force refresh + if (showNotificationsFilterSetting != showNotificationsFilter) + notifications.clear(); + showNotificationsFilter = showNotificationsFilterSetting; + + // Setup the SwipeRefreshLayout. + swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); + recyclerView = rootView.findViewById(R.id.recyclerView); + progressBar = rootView.findViewById(R.id.progressBar); + statusView = rootView.findViewById(R.id.statusView); + appBarOptions = rootView.findViewById(R.id.appBarOptions); + + swipeRefreshLayout.setOnRefreshListener(this); + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); + + loadNotificationsFilter(); + + // Setup the RecyclerView. + recyclerView.setHasFixedSize(true); + layoutManager = new LinearLayoutManager(context); + recyclerView.setLayoutManager(layoutManager); + recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(recyclerView, this, (pos) -> { + NotificationViewData notification = notifications.getPairedItemOrNull(pos); + // We support replies only for now + if (notification instanceof NotificationViewData.Concrete) { + return ((NotificationViewData.Concrete) notification).getStatusViewData(); + } else { + return null; + } + })); + + recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); + + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true), + CardViewMode.NONE, + preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + ); + withMuted = !preferences.getBoolean(PrefKeys.HIDE_MUTED_USERS, false); + + adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), + dataSource, statusDisplayOptions, this, this, this); + alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); + alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); + recyclerView.setAdapter(adapter); + + topLoading = false; + bottomLoading = false; + bottomId = null; + + updateAdapter(); + + Button buttonClear = rootView.findViewById(R.id.buttonClear); + buttonClear.setOnClickListener(v -> confirmClearNotifications()); + buttonFilter = rootView.findViewById(R.id.buttonFilter); + buttonFilter.setOnClickListener(v -> showFilterMenu()); + + if (notifications.isEmpty()) { + swipeRefreshLayout.setEnabled(false); + sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); + } else { + progressBar.setVisibility(View.GONE); + } + + ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + + updateFilterVisibility(); + + return rootView; + } + + private void updateFilterVisibility() { + CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) swipeRefreshLayout.getLayoutParams(); + if (showNotificationsFilter && !showingError) { + appBarOptions.setExpanded(true, false); + appBarOptions.setVisibility(View.VISIBLE); + //Set content behaviour to hide filter on scroll + params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); + } else { + appBarOptions.setExpanded(false, false); + appBarOptions.setVisibility(View.GONE); + //Clear behaviour to hide app bar + params.setBehavior(null); + } + } + + private void confirmClearNotifications() { + new AlertDialog.Builder(getContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.yes, (DialogInterface dia, int which) -> clearNotifications()) + .setNegativeButton(android.R.string.no, null) + .show(); + } + + private void handleFavEvent(FavoriteEvent event) { + Pair posAndNotification = + findReplyPosition(event.getStatusId()); + if (posAndNotification == null) return; + //noinspection ConstantConditions + setFavouriteForStatus(posAndNotification.first, + posAndNotification.second.getStatus(), + event.getFavourite()); + } + + private void handleBookmarkEvent(BookmarkEvent event) { + Pair posAndNotification = + findReplyPosition(event.getStatusId()); + if (posAndNotification == null) return; + //noinspection ConstantConditions + setBookmarkForStatus(posAndNotification.first, + posAndNotification.second.getStatus(), + event.getBookmark()); + } + + private void handleReblogEvent(ReblogEvent event) { + Pair posAndNotification = findReplyPosition(event.getStatusId()); + if (posAndNotification == null) return; + //noinspection ConstantConditions + setReblogForStatus(posAndNotification.first, + posAndNotification.second.getStatus(), + event.getReblog()); + } + + private void handleMuteStatusEvent(MuteConversationEvent event) { + Pair posAndNotification = findReplyPosition(event.getStatusId()); + if (posAndNotification == null) + return; + + int conversationId = posAndNotification.second.getStatus().getConversationId(); + + if(conversationId == -1) { // invalid conversation ID + if(withMuted) { + setMutedStatusForStatus(posAndNotification.first, posAndNotification.second.getStatus(), event.getMute(), event.getMute()); + } else { + notifications.remove(posAndNotification.first); + } + } else { + //noinspection ConstantConditions + if(withMuted) { + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i).asRightOrNull(); + if (notification != null && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION && + notification.getStatus().getConversationId() == conversationId) { + setMutedStatusForStatus(i, notification.getStatus(), event.getMute(), event.getMute()); + } + } + } else { + removeAllByConversationId(conversationId); + } + } + updateAdapter(); + } + + private void handleMuteEvent(MuteEvent event) { + String id = event.getAccountId(); + boolean mute = event.getMute(); + + if(withMuted) { + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i).asRightOrNull(); + if (notification != null + && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION + && notification.getAccount().getId().equals(id) + && !notification.getStatus().isThreadMuted()) { + setMutedStatusForStatus(i, notification.getStatus(), mute, false); + } + } + updateAdapter(); + } else { + removeAllByAccountId(id); + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + Activity activity = getActivity(); + if (activity == null) throw new AssertionError("Activity is null"); + + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't + * guaranteed to be set until then. + * Use a modified scroll listener that both loads more notificationsEnabled as it goes, and hides + * the compose button on down-scroll. */ + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + hideFab = preferences.getBoolean("fabHide", false); + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + super.onScrolled(view, dx, dy); + + ActionButtonActivity activity = (ActionButtonActivity) getActivity(); + FloatingActionButton composeButton = activity.getActionButton(); + + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown()) { + composeButton.hide(); // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown()) { + composeButton.show(); // shows it if we are scrolling up + } + } else if (!composeButton.isShown()) { + composeButton.show(); + } + } + } + + @Override + public void onLoadMore(int totalItemsCount, RecyclerView view) { + NotificationsFragment.this.onLoadMore(); + } + }; + + recyclerView.addOnScrollListener(scrollListener); + + eventHub.getEvents() + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(event -> { + if (event instanceof FavoriteEvent) { + handleFavEvent((FavoriteEvent) event); + } else if (event instanceof BookmarkEvent) { + handleBookmarkEvent((BookmarkEvent) event); + } else if (event instanceof ReblogEvent) { + handleReblogEvent((ReblogEvent) event); + } else if (event instanceof MuteConversationEvent) { + handleMuteStatusEvent((MuteConversationEvent) event); + } else if (event instanceof BlockEvent) { + removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof MuteEvent) { + handleMuteEvent((MuteEvent)event); + } else if (event instanceof PreferenceChangedEvent) { + onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); + } else if (event instanceof EmojiReactEvent) { + handleEmojiReactEvent((EmojiReactEvent) event); + } + }); + } + + @Override + public void onRefresh() { + this.statusView.setVisibility(View.GONE); + this.showingError = false; + Either first = CollectionsKt.firstOrNull(this.notifications); + String topId; + if (first != null && first.isRight()) { + topId = first.asRight().getId(); + } else { + topId = null; + } + sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); + } + + @Override + public void onReply(int position) { + super.reply(notifications.get(position).asRight().getStatus()); + } + + @Override + public void onReblog(final boolean reblog, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + Objects.requireNonNull(status, "Reblog on notification without status"); + timelineCases.reblog(status, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setReblogForStatus(position, status, reblog), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to reblog status: " + status.getId(), t) + ); + } + + private void setReblogForStatus(int position, Status status, boolean reblog) { + status.setReblogged(reblog); + + if (status.getReblog() != null) { + status.getReblog().setReblogged(reblog); + } + + NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); + viewDataBuilder.setReblogged(reblog); + + NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( + viewdata.getType(), viewdata.getId(), viewdata.getAccount(), + viewDataBuilder.createStatusViewData(), viewdata.getEmoji(), viewdata.getTarget()); + notifications.setPairedItem(position, newViewData); + updateAdapter(); + } + + @Override + public void onFavourite(final boolean favourite, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.favourite(status, favourite) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setFavouriteForStatus(position, status, favourite), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to favourite status: " + status.getId(), t) + ); + } + + private void setFavouriteForStatus(int position, Status status, boolean favourite) { + status.setFavourited(favourite); + + if (status.getReblog() != null) { + status.getReblog().setFavourited(favourite); + } + + NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); + viewDataBuilder.setFavourited(favourite); + + NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( + viewdata.getType(), viewdata.getId(), viewdata.getAccount(), + viewDataBuilder.createStatusViewData(), viewdata.getEmoji(), viewdata.getTarget()); + + notifications.setPairedItem(position, newViewData); + updateAdapter(); + } + + @Override + public void onBookmark(final boolean bookmark, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.bookmark(status, bookmark) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setBookmarkForStatus(position, status, bookmark), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to bookmark status: " + status.getId(), t) + ); + } + + private void setBookmarkForStatus(int position, Status status, boolean bookmark) { + status.setBookmarked(bookmark); + + if (status.getReblog() != null) { + status.getReblog().setBookmarked(bookmark); + } + + NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); + viewDataBuilder.setBookmarked(bookmark); + + NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( + viewdata.getType(), viewdata.getId(), viewdata.getAccount(), + viewDataBuilder.createStatusViewData(), viewdata.getEmoji(), viewdata.getTarget()); + + notifications.setPairedItem(position, newViewData); + updateAdapter(); + } + + public void onVoteInPoll(int position, @NonNull List choices) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.voteInPoll(status, choices) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newPoll) -> setVoteForPoll(position, newPoll), + (t) -> Log.d(TAG, + "Failed to vote in poll: " + status.getId(), t) + ); + } + + private void setVoteForPoll(int position, Poll poll) { + + NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); + viewDataBuilder.setPoll(poll); + + NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( + viewdata.getType(), viewdata.getId(), viewdata.getAccount(), + viewDataBuilder.createStatusViewData(), viewdata.getEmoji(), viewdata.getTarget()); + + notifications.setPairedItem(position, newViewData); + updateAdapter(); + } + + @Override + public void onMore(@NonNull View view, int position) { + Notification notification = notifications.get(position).asRight(); + super.more(notification.getStatus(), view, position); + } + + @Override + public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { + Notification notification = notifications.get(position).asRightOrNull(); + if (notification == null || notification.getStatus() == null) return; + super.viewMedia(attachmentIndex, notification.getStatus(), view); + } + + @Override + public void onViewThread(int position) { + Notification notification = notifications.get(position).asRight(); + super.viewThread(notification.getStatus()); + } + + @Override + public void onViewReplyTo(int position) { + Notification notification = notifications.get(position).asRightOrNull(); + if (notification == null) return; + super.onShowReplyTo(notification.getStatus().getInReplyToId()); + } + + @Override + public void onOpenReblog(int position) { + Notification notification = notifications.get(position).asRight(); + onViewAccount(notification.getAccount().getId()); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + NotificationViewData.Concrete old = + (NotificationViewData.Concrete) notifications.getPairedItem(position); + StatusViewData.Concrete statusViewData = + new StatusViewData.Builder(old.getStatusViewData()) + .setIsExpanded(expanded) + .createStatusViewData(); + NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), + old.getId(), old.getAccount(), statusViewData, old.getEmoji(), old.getTarget()); + notifications.setPairedItem(position, notificationViewData); + updateAdapter(); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + NotificationViewData.Concrete old = + (NotificationViewData.Concrete) notifications.getPairedItem(position); + StatusViewData.Concrete statusViewData = + new StatusViewData.Builder(old.getStatusViewData()) + .setIsShowingSensitiveContent(isShowing) + .createStatusViewData(); + NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), + old.getId(), old.getAccount(), statusViewData, old.getEmoji(), old.getTarget()); + notifications.setPairedItem(position, notificationViewData); + updateAdapter(); + } + + @Override + public void onMute(int position, boolean isMuted) { + NotificationViewData.Concrete old = + (NotificationViewData.Concrete) notifications.getPairedItem(position); + StatusViewData.Concrete statusViewData = + new StatusViewData.Builder(old.getStatusViewData()) + .setMuted(isMuted) + .createStatusViewData(); + NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), + old.getId(), old.getAccount(), statusViewData, old.getEmoji(), old.getTarget()); + notifications.setPairedItem(position, notificationViewData); + updateAdapter(); + } + + private void setMutedStatusForStatus(int position, Status status, boolean muted, boolean threadMuted) { + status.setThreadMuted(threadMuted); + + NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); + viewDataBuilder.setThreadMuted(threadMuted); + viewDataBuilder.setMuted(muted); + + NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( + viewdata.getType(), viewdata.getId(), viewdata.getAccount(), + viewDataBuilder.createStatusViewData(), viewdata.getEmoji(), viewdata.getTarget()); + + notifications.setPairedItem(position, newViewData); + } + + + @Override + public void onLoadMore(int position) { + //check bounds before accessing list, + if (notifications.size() >= position && position > 0) { + Notification previous = notifications.get(position - 1).asRightOrNull(); + Notification next = notifications.get(position + 1).asRightOrNull(); + if (previous == null || next == null) { + Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); + return; + } + sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); + Placeholder placeholder = notifications.get(position).asLeft(); + NotificationViewData notificationViewData = + new NotificationViewData.Placeholder(placeholder.id, true); + notifications.setPairedItem(position, notificationViewData); + updateAdapter(); + } else { + Log.d(TAG, "error loading more"); + } + } + + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + if (position < 0 || position >= notifications.size()) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1)); + return; + } + + NotificationViewData notification = notifications.getPairedItem(position); + if (!(notification instanceof NotificationViewData.Concrete)) { + Log.e(TAG, String.format( + "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", + notification == null ? "null" : notification.getClass().getSimpleName(), + position, + notifications.size() - 1 + )); + return; + } + + StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData(); + StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) + .setCollapsed(isCollapsed) + .createStatusViewData(); + + NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification; + NotificationViewData updatedNotification = new NotificationViewData.Concrete( + concreteNotification.getType(), + concreteNotification.getId(), + concreteNotification.getAccount(), + updatedStatus, + concreteNotification.getEmoji(), + concreteNotification.getTarget() + ); + notifications.setPairedItem(position, updatedNotification); + updateAdapter(); + + // Since we cannot notify to the RecyclerView right away because it may be scrolling + // we run this when the RecyclerView is done doing measurements and other calculations. + // To test this is not bs: try getting a notification while scrolling, without wrapping + // notifyItemChanged in a .post() call. App will crash. + recyclerView.post(() -> adapter.notifyItemChanged(position, notification)); + } + + @Override + public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { + onContentCollapsedChange(isCollapsed, position); + } + + private void clearNotifications() { + //Cancel all ongoing requests + swipeRefreshLayout.setRefreshing(false); + resetNotificationsLoad(); + + //Show friend elephant + this.statusView.setVisibility(View.VISIBLE); + this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + updateFilterVisibility(); + + //Update adapter + updateAdapter(); + + //Execute clear notifications request + Call call = mastodonApi.clearNotifications(); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (isAdded()) { + if (!response.isSuccessful()) { + //Reload notifications on failure + fullyRefreshWithProgressBar(true); + } + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + //Reload notifications on failure + fullyRefreshWithProgressBar(true); + } + }); + callList.add(call); + } + + private void resetNotificationsLoad() { + for (Call callItem : callList) { + callItem.cancel(); + } + callList.clear(); + bottomLoading = false; + topLoading = false; + + //Disable load more + bottomId = null; + + //Clear exists notifications + notifications.clear(); + } + + + private void showFilterMenu() { + List notificationsList = Notification.Type.Companion.getAsList(); + List list = new ArrayList<>(); + for (Notification.Type type : notificationsList) { + // ignore chat messages, as we don't work with them in main notification fragment + if(type == Notification.Type.CHAT_MESSAGE) + continue; + + list.add(getNotificationText(type)); + } + + ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); + PopupWindow window = new PopupWindow(getContext()); + View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); + final ListView listView = view.findViewById(R.id.listView); + view.findViewById(R.id.buttonApply) + .setOnClickListener(v -> { + SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); + Set excludes = new HashSet<>(); + for (int i = 0; i < notificationsList.size(); i++) { + if (!checkedItems.get(i, false)) + excludes.add(notificationsList.get(i)); + } + window.dismiss(); + applyFilterChanges(excludes); + + }); + + listView.setAdapter(adapter); + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + for (int i = 0; i < notificationsList.size(); i++) { + if (!notificationFilter.contains(notificationsList.get(i))) + listView.setItemChecked(i, true); + } + window.setContentView(view); + window.setFocusable(true); + window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + window.showAsDropDown(buttonFilter); + + } + + private String getNotificationText(Notification.Type type) { + switch (type) { + case MENTION: + return getString(R.string.notification_mention_name); + case FAVOURITE: + return getString(R.string.notification_favourite_name); + case REBLOG: + return getString(R.string.notification_boost_name); + case FOLLOW: + return getString(R.string.notification_follow_name); + case FOLLOW_REQUEST: + return getString(R.string.notification_follow_request_name); + case POLL: + return getString(R.string.notification_poll_name); + case EMOJI_REACTION: + return getString(R.string.notification_emoji_name); + case MOVE: + return getString(R.string.notification_move_name); + case STATUS: + return getString(R.string.notification_subscription_name); + default: + return "Unknown"; + } + } + + private void applyFilterChanges(Set newSet) { + List notifications = Notification.Type.Companion.getAsList(); + boolean isChanged = false; + for (Notification.Type type : notifications) { + if (notificationFilter.contains(type) && !newSet.contains(type)) { + notificationFilter.remove(type); + isChanged = true; + } else if (!notificationFilter.contains(type) && newSet.contains(type)) { + notificationFilter.add(type); + isChanged = true; + } + } + if (isChanged) { + saveNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + + } + + private void loadNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + notificationFilter.clear(); + notificationFilter.addAll(NotificationTypeConverterKt.deserialize( + account.getNotificationsFilter())); + } + } + + private void saveNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); + accountManager.saveAccount(account); + } + } + + @Override + public void onViewTag(String tag) { + super.viewTag(tag); + } + + @Override + public void onViewAccount(String id) { + super.viewAccount(id); + } + + @Override + public void onMute(boolean mute, String id, int position, boolean notifications) { + // No muting from notifications yet + } + + @Override + public void onBlock(boolean block, String id, int position) { + // No blocking from notifications yet + } + + @Override + public void onRespondToFollowRequest(boolean accept, String id, int position) { + Single request = accept ? + mastodonApi.authorizeFollowRequestObservable(id) : + mastodonApi.rejectFollowRequestObservable(id); + request.observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (relationship) -> fullyRefreshWithProgressBar(true), + (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) + ); + } + + @Override + public void onViewStatusForNotificationId(String notificationId) { + for (Either either : notifications) { + Notification notification = either.asRightOrNull(); + if (notification != null && notification.getId().equals(notificationId)) { + super.viewThread(notification.getStatus()); + return; + } + } + Log.w(TAG, "Didn't find a notification for ID: " + notificationId); + } + + private void onPreferenceChanged(String key) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + + switch (key) { + case "fabHide": { + hideFab = sharedPreferences.getBoolean("fabHide", false); + break; + } + case "mediaPreviewEnabled": { + boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); + if (enabled != adapter.isMediaPreviewEnabled()) { + adapter.setMediaPreviewEnabled(enabled); + fullyRefresh(); + } + } + case "showNotificationsFilter": { + if (isAdded()) { + showNotificationsFilter = sharedPreferences.getBoolean("showNotificationsFilter", true); + updateFilterVisibility(); + fullyRefreshWithProgressBar(true); + } + } + case PrefKeys.HIDE_MUTED_USERS: { + withMuted = !sharedPreferences.getBoolean(PrefKeys.HIDE_MUTED_USERS, false); + fullyRefresh(); + } + } + } + + @Override + public void removeItem(int position) { + notifications.remove(position); + updateAdapter(); + } + + private void removeAllByConversationId(int conversationId) { + // using iterator to safely remove items while iterating + Iterator> iterator = notifications.iterator(); + while (iterator.hasNext()) { + Either placeholderOrNotification = iterator.next(); + Notification notification = placeholderOrNotification.asRightOrNull(); + if (notification != null && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION && + notification.getStatus().getConversationId() == conversationId) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void removeAllByAccountId(String accountId) { + // using iterator to safely remove items while iterating + Iterator> iterator = notifications.iterator(); + while (iterator.hasNext()) { + Either notification = iterator.next(); + Notification maybeNotification = notification.asRightOrNull(); + if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void onLoadMore() { + if (bottomId == null) { + // already loaded everything + return; + } + + // Check for out-of-bounds when loading + // This is required to allow full-timeline reloads of collapsible statuses when the settings + // change. + if (notifications.size() > 0) { + Either last = notifications.get(notifications.size() - 1); + if (last.isRight()) { + final Placeholder placeholder = newPlaceholder(); + notifications.add(new Either.Left<>(placeholder)); + NotificationViewData viewData = + new NotificationViewData.Placeholder(placeholder.id, true); + notifications.setPairedItem(notifications.size() - 1, viewData); + updateAdapter(); + } + } + + sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); + } + + private Placeholder newPlaceholder() { + Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); + maxPlaceholderId--; + return placeholder; + } + + private void jumpToTop() { + if (isAdded()) { + appBarOptions.setExpanded(true, false); + layoutManager.scrollToPosition(0); + scrollListener.reset(); + } + } + + private void sendFetchNotificationsRequest(String fromId, String uptoId, + final FetchEnd fetchEnd, final int pos) { + /* If there is a fetch already ongoing, record however many fetches are requested and + * fulfill them after it's complete. */ + if (fetchEnd == FetchEnd.TOP && topLoading) { + return; + } + if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { + return; + } + if (fetchEnd == FetchEnd.TOP) { + topLoading = true; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = true; + } + + Call> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null, withMuted); + + call.enqueue(new Callback>() { + @Override + public void onResponse(@NonNull Call> call, + @NonNull Response> response) { + if (response.isSuccessful()) { + String linkHeader = response.headers().get("Link"); + onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); + } else { + onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); + } + } + + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + if (!call.isCanceled()) + onFetchNotificationsFailure((Exception) t, fetchEnd, pos); + } + }); + callList.add(call); + } + + private void onFetchNotificationsSuccess(List notifications, String linkHeader, + FetchEnd fetchEnd, int pos) { + List links = HttpHeaderLink.parse(linkHeader); + HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next"); + String fromId = null; + if (next != null) { + fromId = next.uri.getQueryParameter("max_id"); + } + + switch (fetchEnd) { + case TOP: { + update(notifications, this.notifications.isEmpty() ? fromId : null); + break; + } + case MIDDLE: { + replacePlaceholderWithNotifications(notifications, pos); + break; + } + case BOTTOM: { + + if (!this.notifications.isEmpty() + && !this.notifications.get(this.notifications.size() - 1).isRight()) { + this.notifications.remove(this.notifications.size() - 1); + updateAdapter(); + } + + if (adapter.getItemCount() > 1) { + addItems(notifications, fromId); + } else { + update(notifications, fromId); + } + + break; + } + } + + saveNewestNotificationId(notifications); + + if (fetchEnd == FetchEnd.TOP) { + topLoading = false; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + + if (notifications.size() == 0 && adapter.getItemCount() == 0) { + this.statusView.setVisibility(View.VISIBLE); + this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + } else { + swipeRefreshLayout.setEnabled(true); + } + updateFilterVisibility(); + swipeRefreshLayout.setRefreshing(false); + progressBar.setVisibility(View.GONE); + } + + private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) { + swipeRefreshLayout.setRefreshing(false); + if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { + Placeholder placeholder = notifications.get(position).asLeft(); + NotificationViewData placeholderVD = + new NotificationViewData.Placeholder(placeholder.id, false); + notifications.setPairedItem(position, placeholderVD); + updateAdapter(); + } else if (this.notifications.isEmpty()) { + this.statusView.setVisibility(View.VISIBLE); + swipeRefreshLayout.setEnabled(false); + this.showingError = true; + if (exception instanceof IOException) { + this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { + this.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } else { + this.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { + this.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } + updateFilterVisibility(); + } + Log.e(TAG, "Fetch failure: " + exception.getMessage()); + + if (fetchEnd == FetchEnd.TOP) { + topLoading = false; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + + progressBar.setVisibility(View.GONE); + } + + private void saveNewestNotificationId(List notifications) { + + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + String lastNotificationId = account.getLastNotificationId(); + + for (Notification noti : notifications) { + if (isLessThan(lastNotificationId, noti.getId())) { + lastNotificationId = noti.getId(); + } + } + + if (!account.getLastNotificationId().equals(lastNotificationId)) { + Log.d(TAG, "saving newest noti id: " + lastNotificationId); + account.setLastNotificationId(lastNotificationId); + accountManager.saveAccount(account); + } + } + } + + private void update(@Nullable List newNotifications, @Nullable String fromId) { + if (ListUtils.isEmpty(newNotifications)) { + updateAdapter(); + return; + } + if (fromId != null) { + bottomId = fromId; + } + List> liftedNew = + liftNotificationList(newNotifications); + if (notifications.isEmpty()) { + notifications.addAll(liftedNew); + } else { + int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); + for (int i = 0; i < index; i++) { + notifications.remove(0); + } + + int newIndex = liftedNew.indexOf(notifications.get(0)); + if (newIndex == -1) { + if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { + liftedNew.add(new Either.Left<>(newPlaceholder())); + } + notifications.addAll(0, liftedNew); + } else { + notifications.addAll(0, liftedNew.subList(0, newIndex)); + } + } + updateAdapter(); + } + + private void addItems(List newNotifications, @Nullable String fromId) { + bottomId = fromId; + if (ListUtils.isEmpty(newNotifications)) { + return; + } + int end = notifications.size(); + List> liftedNew = liftNotificationList(newNotifications); + Either last = notifications.get(end - 1); + if (last != null && liftedNew.indexOf(last) == -1) { + notifications.addAll(liftedNew); + updateAdapter(); + } + } + + private void replacePlaceholderWithNotifications(List newNotifications, int pos) { + // Remove placeholder + notifications.remove(pos); + + if (ListUtils.isEmpty(newNotifications)) { + updateAdapter(); + return; + } + + List> liftedNew = liftNotificationList(newNotifications); + + // If we fetched less posts than in the limit, it means that the hole is not filled + // If we fetched at least as much it means that there are more posts to load and we should + // insert new placeholder + if (newNotifications.size() >= LOAD_AT_ONCE) { + liftedNew.add(new Either.Left<>(newPlaceholder())); + } + + notifications.addAll(pos, liftedNew); + updateAdapter(); + } + + private final Function1> notificationLifter = + Either.Right::new; + + private List> liftNotificationList(List list) { + return CollectionsKt.map(list, notificationLifter); + } + + private void fullyRefreshWithProgressBar(boolean isShow) { + resetNotificationsLoad(); + if (isShow) { + progressBar.setVisibility(View.VISIBLE); + statusView.setVisibility(View.GONE); + } + updateAdapter(); + sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); + } + + private void fullyRefresh() { + fullyRefreshWithProgressBar(false); + } + + @Nullable + private Pair findReplyPosition(@NonNull String statusId) { + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i).asRightOrNull(); + if (notification != null + && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION + && (statusId.equals(notification.getStatus().getId()) + || (notification.getStatus().getReblog() != null + && statusId.equals(notification.getStatus().getReblog().getId())))) { + return new Pair<>(i, notification); + } + } + return null; + } + + private void updateAdapter() { + differ.submitList(notifications.getPairedCopy()); + } + + private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { + @Override + public void onInserted(int position, int count) { + if (isAdded()) { + adapter.notifyItemRangeInserted(position, count); + Context context = getContext(); + // scroll up when new items at the top are loaded while being at the start + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.getItemCount() != count) { + recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); + } + } + } + + @Override + public void onRemoved(int position, int count) { + adapter.notifyItemRangeRemoved(position, count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + adapter.notifyItemMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, Object payload) { + adapter.notifyItemRangeChanged(position, count, payload); + } + }; + + private final AsyncListDiffer + differ = new AsyncListDiffer<>(listUpdateCallback, + new AsyncDifferConfig.Builder<>(diffCallback).build()); + + private final NotificationsAdapter.AdapterDataSource dataSource = + new NotificationsAdapter.AdapterDataSource() { + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public NotificationViewData getItemAt(int pos) { + return differ.getCurrentList().get(pos); + } + }; + + private static final DiffUtil.ItemCallback diffCallback + = new DiffUtil.ItemCallback() { + + @Override + public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { + return oldItem.getViewDataId() == newItem.getViewDataId(); + } + + @Override + public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { + return false; + } + + @Nullable + @Override + public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { + if (oldItem.deepEquals(newItem)) { + //If items are equal - update timestamp only + return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); + } else + // If items are different - update a whole view holder + return null; + } + }; + + @Override + public void onResume() { + super.onResume(); + String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); + Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); + if (!notificationFilter.equals(accountNotificationFilter)) { + loadNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + startUpdateTimestamp(); + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private void startUpdateTimestamp() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) + .subscribe( + interval -> updateAdapter() + ); + } + + } + + @Override + public void onReselect() { + jumpToTop(); + } + + private void setEmojiReactForStatus(int position, Status newStatus) { + NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); + + NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( + viewdata.getType(), viewdata.getId(), viewdata.getAccount(), + ViewDataUtils.statusToViewData(newStatus, false, false), + viewdata.getEmoji(), viewdata.getTarget()); + + notifications.setPairedItem(position, newViewData); + updateAdapter(); + } + + private void handleEmojiReactEvent(EmojiReactEvent event) { + Pair posAndNotification = + findReplyPosition(event.getNewStatus().getActionableId()); + if (posAndNotification == null) return; + //noinspection ConstantConditions + setEmojiReactForStatus(posAndNotification.first, event.getNewStatus()); + } + + + @Override + public void onEmojiReact(final boolean react, final String emoji, final String statusId) { + Pair posAndNotification = findReplyPosition(statusId); + if (posAndNotification == null) + return; + + timelineCases.react(emoji, statusId, react) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setEmojiReactForStatus(posAndNotification.first, newStatus), + (t) -> Log.d(TAG, + "Failed to react with " + emoji + " on status: " + statusId, t) + ); + + } + + @Override + public void onEmojiReactMenu(@NonNull View view, final EmojiReaction emoji, final String statusId) { + super.emojiReactMenu(statusId, emoji, view, this); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java new file mode 100644 index 0000000..a5baf0e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -0,0 +1,655 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import android.Manifest; +import android.app.DownloadManager; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Environment; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.app.ActivityOptionsCompat; +import androidx.core.view.ViewCompat; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; + +import com.keylesspalace.tusky.BaseActivity; +import com.keylesspalace.tusky.BottomSheetActivity; +import com.keylesspalace.tusky.MainActivity; +import com.keylesspalace.tusky.AccountListActivity; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.PostLookupFallbackBehavior; +import com.keylesspalace.tusky.ViewMediaActivity; +import com.keylesspalace.tusky.ViewTagActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; +import com.keylesspalace.tusky.components.report.ReportActivity; +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Filter; +import com.keylesspalace.tusky.entity.PollOption; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.EmojiReaction; +import com.keylesspalace.tusky.network.MastodonApi; +import com.keylesspalace.tusky.network.TimelineCases; +import com.keylesspalace.tusky.settings.PrefKeys; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.view.MuteAccountDialog; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; +import com.keylesspalace.tusky.interfaces.StatusActionListener; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.inject.Inject; + +import kotlin.Unit; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static com.uber.autodispose.AutoDispose.autoDisposable; +import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; + +/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an + * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature + * of that is complicated by how they're coupled with Status and Notification and the corresponding + * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also + * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear + * up what needs to be where. */ +public abstract class SFragment extends BaseFragment implements Injectable { + + protected abstract void removeItem(int position); + + protected abstract void onReblog(final boolean reblog, final int position); + + private BottomSheetActivity bottomSheetActivity; + + private static List filters; + private boolean filterRemoveRegex; + private Matcher filterRemoveRegexMatcher; + private static Matcher alphanumeric = Pattern.compile("^\\w+$").matcher(""); + private boolean filterMuted; + + @Inject + public MastodonApi mastodonApi; + @Inject + public AccountManager accountManager; + @Inject + public TimelineCases timelineCases; + + private static final String TAG = "SFragment"; + + @Override + public void startActivity(Intent intent) { + super.startActivity(intent); + getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof BottomSheetActivity) { + bottomSheetActivity = (BottomSheetActivity) context; + } else { + throw new IllegalStateException("Fragment must be attached to a BottomSheetActivity!"); + } + } + + protected void openReblog(@Nullable final Status status) { + if (status == null) return; + bottomSheetActivity.viewAccount(status.getAccount().getId()); + } + + protected void viewThread(Status status) { + Status actionableStatus = status.getActionableStatus(); + bottomSheetActivity.viewThread(actionableStatus.getId(), actionableStatus.getUrl()); + } + + protected void viewAccount(String accountId) { + bottomSheetActivity.viewAccount(accountId); + } + + public void onViewUrl(String url) { + bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER); + } + + protected void onShowReplyTo(String replyToId) { + bottomSheetActivity.viewThread(replyToId, null); + } + + protected void reply(Status status) { + String inReplyToId = status.getActionableId(); + Status actionableStatus = status.getActionableStatus(); + Status.Visibility replyVisibility = actionableStatus.getVisibility(); + String contentWarning = actionableStatus.getSpoilerText(); + Status.Mention[] mentions = actionableStatus.getMentions(); + Set mentionedUsernames = new LinkedHashSet<>(); + mentionedUsernames.add(actionableStatus.getAccount().getUsername()); + String loggedInUsername = null; + AccountEntity activeAccount = accountManager.getActiveAccount(); + if (activeAccount != null) { + loggedInUsername = activeAccount.getUsername(); + } + for (Status.Mention mention : mentions) { + mentionedUsernames.add(mention.getUsername()); + } + mentionedUsernames.remove(loggedInUsername); + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setInReplyToId(inReplyToId); + composeOptions.setReplyVisibility(replyVisibility); + composeOptions.setContentWarning(contentWarning); + composeOptions.setMentionedUsernames(mentionedUsernames); + composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); + composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString()); + + Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); + getActivity().startActivity(intent); + } + + protected void emojiReactMenu(@NonNull final String statusId, @NonNull final EmojiReaction reaction, View view, final StatusActionListener listener) { + PopupMenu popup = new PopupMenu(getContext(), view); + + popup.inflate(R.menu.emoji_reaction_more); + Menu menu = popup.getMenu(); + menu.findItem(R.id.emoji_react).setVisible(!reaction.getMe()); + menu.findItem(R.id.emoji_unreact).setVisible(reaction.getMe()); + + popup.setOnMenuItemClickListener(item -> { + switch (item.getItemId()) { + case R.id.emoji_react: + listener.onEmojiReact(true, reaction.getName(), statusId); + return true; + case R.id.emoji_unreact: + listener.onEmojiReact(false, reaction.getName(), statusId); + return true; + case R.id.emoji_reacted_by: + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REACTED, statusId, reaction.getName()); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + + return true; + } + return false; + }); + popup.show(); + } + + protected void more(@NonNull final Status status, View view, final int position) { + final String id = status.getActionableId(); + final String accountId = status.getActionableStatus().getAccount().getId(); + final String accountUsername = status.getActionableStatus().getAccount().getUsername(); + final String statusUrl = status.getActionableStatus().getUrl(); + List accounts = accountManager.getAllAccountsOrderedByActive(); + String openAsTitle = null; + + String loggedInAccountId = null; + AccountEntity activeAccount = accountManager.getActiveAccount(); + if (activeAccount != null) { + loggedInAccountId = activeAccount.getAccountId(); + } + + PopupMenu popup = new PopupMenu(getContext(), view); + // Give a different menu depending on whether this is the user's own toot or not. + if (loggedInAccountId == null || !loggedInAccountId.equals(accountId)) { + popup.inflate(R.menu.status_more); + Menu menu = popup.getMenu(); + menu.findItem(R.id.status_download_media).setVisible(!status.getAttachments().isEmpty()); + } else { + popup.inflate(R.menu.status_more_for_user); + Menu menu = popup.getMenu(); + switch (status.getVisibility()) { + case PUBLIC: + case UNLISTED: { + final String textId = + getString(status.isPinned() ? R.string.unpin_action : R.string.pin_action); + menu.add(0, R.id.pin, 1, textId); + break; + } + case PRIVATE: { + boolean reblogged = status.getReblogged(); + if (status.getReblog() != null) reblogged = status.getReblog().getReblogged(); + menu.findItem(R.id.status_reblog_private).setVisible(!reblogged); + menu.findItem(R.id.status_unreblog_private).setVisible(reblogged); + break; + } + } + } + + Menu menu = popup.getMenu(); + MenuItem openAsItem = menu.findItem(R.id.status_open_as); + switch (accounts.size()) { + case 0: + case 1: + openAsItem.setVisible(false); + break; + case 2: + for (AccountEntity account : accounts) { + if (account != activeAccount) { + openAsTitle = String.format(getString(R.string.action_open_as), account.getFullName()); + break; + } + } + break; + default: + openAsTitle = String.format(getString(R.string.action_open_as), "…"); + break; + } + openAsItem.setTitle(openAsTitle); + + // maybe not a best check + if(status.getPleroma() != null) { + boolean showMute = true; // predict state + + if(status.isThreadMuted() == true) { + showMute = false; + } + + // show mutes only for Pleroma because Mastodon don't handle them in sane way + // e.g. why you can only mute threads where you were participated? + menu.findItem(R.id.status_mute_conversation).setVisible(showMute); + menu.findItem(R.id.status_unmute_conversation).setVisible(!showMute); + } + + popup.setOnMenuItemClickListener(item -> { + switch (item.getItemId()) { + case R.id.status_share_content: { + Status statusToShare = status; + if (statusToShare.getReblog() != null) + statusToShare = statusToShare.getReblog(); + + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + + String stringToShare = statusToShare.getAccount().getUsername() + + " - " + + statusToShare.getContent().toString(); + sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare); + sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl); + sendIntent.setType("text/plain"); + startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_content_to))); + return true; + } + case R.id.status_share_link: { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl); + sendIntent.setType("text/plain"); + startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_link_to))); + return true; + } + case R.id.status_copy_link: { + ClipboardManager clipboard = (ClipboardManager) + getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(null, statusUrl); + clipboard.setPrimaryClip(clip); + return true; + } + case R.id.status_open_in_web: { + LinkHelper.openLinkInBrowser(Uri.parse(statusUrl), getContext()); + return true; + } + case R.id.status_open_as: { + showOpenAsDialog(statusUrl, item.getTitle()); + return true; + } + case R.id.status_download_media: { + requestDownloadAllMedia(status); + return true; + } + case R.id.status_mute: { + onMute(accountId, accountUsername); + return true; + } + case R.id.status_block: { + onBlock(accountId, accountUsername); + return true; + } + case R.id.status_report: { + openReportPage(accountId, accountUsername, id); + return true; + } + case R.id.status_mute_conversation: { + timelineCases.muteConversation(status, true); + return true; + } + case R.id.status_unmute_conversation: { + timelineCases.muteConversation(status, false); + return true; + } + case R.id.status_unreblog_private: { + onReblog(false, position); + return true; + } + case R.id.status_reblog_private: { + onReblog(true, position); + return true; + } + case R.id.status_delete: { + showConfirmDeleteDialog(id, position); + return true; + } + case R.id.pin: { + timelineCases.pin(status, !status.isPinned()); + return true; + } + } + return false; + }); + popup.show(); + } + + private void onMute(String accountId, String accountUsername) { + MuteAccountDialog.showMuteAccountDialog( + this.getActivity(), + accountUsername, + (notifications, duration) -> { + timelineCases.mute(accountId, notifications, duration); + return Unit.INSTANCE; + } + ); + } + + private void onBlock(String accountId, String accountUsername) { + new AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.dialog_block_warning, accountUsername)) + .setPositiveButton(android.R.string.ok, (__, ___) -> timelineCases.block(accountId)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private static boolean accountIsInMentions(AccountEntity account, Status.Mention[] mentions) { + if (account == null) { + return false; + } + + for (Status.Mention mention : mentions) { + if (account.getUsername().equals(mention.getUsername())) { + Uri uri = Uri.parse(mention.getUrl()); + if (uri != null && account.getDomain().equals(uri.getHost())) { + return true; + } + } + } + return false; + } + + protected void viewMedia(int urlIndex, Status status, @Nullable View view) { + final Status actionable = status.getActionableStatus(); + final Attachment active = actionable.getAttachments().get(urlIndex); + Attachment.Type type = active.getType(); + switch (type) { + case GIFV: + case VIDEO: + case IMAGE: + case AUDIO: { + final List attachments = AttachmentViewData.list(actionable); + final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments, + urlIndex); + if (view != null) { + String url = active.getUrl(); + ViewCompat.setTransitionName(view, url); + ActivityOptionsCompat options = + ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), + view, url); + startActivity(intent, options.toBundle()); + } else { + startActivity(intent); + } + break; + } + default: + case UNKNOWN: { + LinkHelper.openLink(active.getUrl(), getContext()); + break; + } + } + } + + protected void viewTag(String tag) { + Intent intent = new Intent(getContext(), ViewTagActivity.class); + intent.putExtra("hashtag", tag); + startActivity(intent); + } + + protected void openReportPage(String accountId, String accountUsername, String statusId) { + startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId)); + } + + protected void showConfirmDeleteDialog(final String id, final int position) { + new AlertDialog.Builder(getActivity()) + .setMessage(R.string.dialog_delete_toot_warning) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + timelineCases.delete(id) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + deletedStatus -> { + }, + error -> { + Log.w("SFragment", "error deleting status", error); + Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); + }); + removeItem(position); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void showConfirmEditDialog(final String id, final int position, final Status status) { + if (getActivity() == null) { + return; + } + new AlertDialog.Builder(getActivity()) + .setMessage(R.string.dialog_redraft_toot_warning) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + timelineCases.delete(id) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(deletedStatus -> { + removeItem(position); + + if (deletedStatus.isEmpty()) { + deletedStatus = status.toDeletedStatus(); + } + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setTootText(deletedStatus.getText()); + composeOptions.setInReplyToId(deletedStatus.getInReplyToId()); + composeOptions.setVisibility(deletedStatus.getVisibility()); + composeOptions.setContentWarning(deletedStatus.getSpoilerText()); + composeOptions.setMediaAttachments(deletedStatus.getAttachments()); + composeOptions.setSensitive(deletedStatus.getSensitive()); + composeOptions.setModifiedInitialState(true); + if (deletedStatus.getPoll() != null) { + composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); + } + + Intent intent = ComposeActivity + .startIntent(getContext(), composeOptions); + startActivity(intent); + }, + error -> { + Log.w("SFragment", "error deleting status", error); + Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); + }); + + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void openAsAccount(String statusUrl, AccountEntity account) { + accountManager.setActiveAccount(account); + Intent intent = new Intent(getContext(), MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.putExtra(MainActivity.STATUS_URL, statusUrl); + startActivity(intent); + ((BaseActivity) getActivity()).finishWithoutSlideOutAnimation(); + } + + private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) { + BaseActivity activity = (BaseActivity) getActivity(); + activity.showAccountChooserDialog(dialogTitle, false, account -> openAsAccount(statusUrl, account)); + } + + private void downloadAllMedia(Status status) { + Toast.makeText(getContext(), R.string.downloading_media, Toast.LENGTH_SHORT).show(); + for (Attachment attachment : status.getAttachments()) { + String url = attachment.getUrl(); + Uri uri = Uri.parse(url); + String filename = uri.getLastPathSegment(); + + DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Request request = new DownloadManager.Request(uri); + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename); + downloadManager.enqueue(request); + } + } + + private void requestDownloadAllMedia(Status status) { + String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status); + } else { + Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show(); + } + }); + } + + public boolean isFilteringMuted() { + return filterMuted; + } + + public void updateMuteFilter(@NonNull SharedPreferences pref, boolean reload) { + filterMuted = pref.getBoolean(PrefKeys.HIDE_MUTED_USERS, false); + + if(reload) { + refreshAfterApplyingFilters(); + } + } + + public void reloadFilters(SharedPreferences pref, boolean forceRefresh) { + if(pref != null) { + updateMuteFilter(pref, false); // will be reloaded later + } + + if (filters != null && !forceRefresh) { + applyFilters(forceRefresh); + return; + } + + mastodonApi.getFilters().enqueue(new Callback>() { + @Override + public void onResponse(@NonNull Call> call, @NonNull Response> response) { + filters = response.body(); + if (response.isSuccessful() && filters != null) { + applyFilters(forceRefresh); + } else { + Log.e(TAG, "Error getting filters from server"); + } + } + + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + Log.e(TAG, "Error getting filters from server", t); + } + }); + } + + protected boolean filterIsRelevant(@NonNull Filter filter) { + // Called when building local filter expression + // Override to select relevant filters for your fragment + return false; + } + + protected void refreshAfterApplyingFilters() { + // Called after filters are updated + // Override to refresh your fragment + } + + @VisibleForTesting + public boolean shouldFilterStatus(Status status) { + if (filterMuted && status.getMuted()) { + return true; + } + + if (filterRemoveRegex && status.getPoll() != null) { + for (PollOption option : status.getPoll().getOptions()) { + if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) { + return true; + } + } + } + + return (filterRemoveRegex && (filterRemoveRegexMatcher.reset(status.getActionableStatus().getContent()).find() + || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find()))); + } + + public void applyFilters(boolean refresh) { + List tokens = new ArrayList<>(); + for (Filter filter : filters) { + if (filterIsRelevant(filter)) { + tokens.add(filterToRegexToken(filter)); + } + } + filterRemoveRegex = !tokens.isEmpty(); + if (filterRemoveRegex) { + filterRemoveRegexMatcher = Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE).matcher(""); + } + if (refresh) { + refreshAfterApplyingFilters(); + } + } + + private static String filterToRegexToken(Filter filter) { + String phrase = filter.getPhrase(); + String quotedPhrase = Pattern.quote(phrase); + return (filter.getWholeWord() && alphanumeric.reset(phrase).matches()) ? // "whole word" should only apply to alphanumeric filters, #1543 + String.format("(^|\\W)%s($|\\W)", quotedPhrase) : + quotedPhrase; + } + + public static void flushFilters() { + filters = null; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java new file mode 100644 index 0000000..1349a59 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java @@ -0,0 +1,53 @@ +/* Copyright 2019 kyori19 + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import android.app.Dialog; +import android.app.TimePickerDialog; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; + +import com.keylesspalace.tusky.components.compose.ComposeActivity; + +import java.util.Calendar; +import java.util.TimeZone; + +public class TimePickerFragment extends DialogFragment { + + public static final String PICKER_TIME_HOUR = "picker_time_hour"; + public static final String PICKER_TIME_MINUTE = "picker_time_minute"; + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + Bundle args = getArguments(); + Calendar calendar = Calendar.getInstance(TimeZone.getDefault()); + if (args != null) { + calendar.set(Calendar.HOUR_OF_DAY, args.getInt(PICKER_TIME_HOUR)); + calendar.set(Calendar.MINUTE, args.getInt(PICKER_TIME_MINUTE)); + } + + return new TimePickerDialog(getContext(), + android.R.style.Theme_DeviceDefault_Dialog, + (ComposeActivity) getActivity(), + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE), + true); + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java new file mode 100644 index 0000000..250ccb5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -0,0 +1,1648 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +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.arch.core.util.Function; +import androidx.core.util.Pair; +import androidx.core.widget.ContentLoadingProgressBar; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.keylesspalace.tusky.AccountListActivity; +import com.keylesspalace.tusky.BaseActivity; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; +import com.keylesspalace.tusky.adapter.TimelineAdapter; +import com.keylesspalace.tusky.appstore.*; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.entity.*; +import com.keylesspalace.tusky.interfaces.ActionButtonActivity; +import com.keylesspalace.tusky.interfaces.RefreshableFragment; +import com.keylesspalace.tusky.interfaces.ReselectableFragment; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.network.MastodonApi; +import com.keylesspalace.tusky.repository.Placeholder; +import com.keylesspalace.tusky.repository.TimelineRepository; +import com.keylesspalace.tusky.repository.TimelineRequestMode; +import com.keylesspalace.tusky.settings.PrefKeys; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.Either; +import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; +import com.keylesspalace.tusky.util.ListUtils; +import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.StringUtils; +import com.keylesspalace.tusky.util.ViewDataUtils; +import com.keylesspalace.tusky.view.BackgroundMessageView; +import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.concurrent.TimeUnit; +import java.util.Objects; + +import javax.inject.Inject; + +import at.connyduck.sparkbutton.helpers.Utils; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import kotlin.Unit; +import kotlin.collections.CollectionsKt; +import kotlin.jvm.functions.Function1; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static com.uber.autodispose.AutoDispose.autoDisposable; +import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; + +public class TimelineFragment extends SFragment implements + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + Injectable, ReselectableFragment, RefreshableFragment { + private static final String TAG = "TimelineF"; // logging tag + private static final String KIND_ARG = "kind"; + private static final String ID_ARG = "id"; + private static final String HASHTAGS_ARG = "hastags"; + private static final String ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh"; + + private static final int LOAD_AT_ONCE = 30; + private boolean isSwipeToRefreshEnabled = true; + private boolean isNeedRefresh; + + public enum Kind { + HOME, + PUBLIC_LOCAL, + PUBLIC_FEDERATED, + TAG, + USER, + USER_PINNED, + USER_WITH_REPLIES, + FAVOURITES, + LIST, + BOOKMARKS + } + + private enum FetchEnd { + TOP, + BOTTOM, + MIDDLE + } + + @Inject + public EventHub eventHub; + @Inject + TimelineRepository timelineRepo; + + @Inject + public AccountManager accountManager; + + private boolean eventRegistered = false; + + private SwipeRefreshLayout swipeRefreshLayout; + private RecyclerView recyclerView; + private ProgressBar progressBar; + private ContentLoadingProgressBar topProgressBar; + private BackgroundMessageView statusView; + + private TimelineAdapter adapter; + private Kind kind; + private String id; + private List tags; + /** + * For some timeline kinds we must use LINK headers and not just status ids. + */ + private String nextId; + private LinearLayoutManager layoutManager; + private EndlessOnScrollListener scrollListener; + private boolean filterRemoveReplies; + private boolean filterRemoveReblogs; + private boolean hideFab; + private boolean bottomLoading; + + private boolean didLoadEverythingBottom; + private boolean alwaysShowSensitiveMedia; + private boolean alwaysOpenSpoiler; + private boolean initialUpdateFailed = false; + + private PairedList, StatusViewData> statuses = + new PairedList<>(new Function, StatusViewData>() { + @Override + public StatusViewData apply(Either input) { + Status status = input.asRightOrNull(); + if (status != null) { + return ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia, + alwaysOpenSpoiler + ); + } else { + Placeholder placeholder = input.asLeft(); + return new StatusViewData.Placeholder(placeholder.getId(), false); + } + } + }); + + public static TimelineFragment newInstance(Kind kind) { + return newInstance(kind, null); + } + + public static TimelineFragment newInstance(Kind kind, @Nullable String hashtagOrId) { + return newInstance(kind, hashtagOrId, true); + } + + public static TimelineFragment newInstance(Kind kind, @Nullable String hashtagOrId, boolean enableSwipeToRefresh) { + TimelineFragment fragment = new TimelineFragment(); + Bundle arguments = new Bundle(3); + arguments.putString(KIND_ARG, kind.name()); + arguments.putString(ID_ARG, hashtagOrId); + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh); + fragment.setArguments(arguments); + return fragment; + } + + public static TimelineFragment newHashtagInstance(@NonNull List hashtags) { + TimelineFragment fragment = new TimelineFragment(); + Bundle arguments = new Bundle(3); + arguments.putString(KIND_ARG, Kind.TAG.name()); + arguments.putStringArrayList(HASHTAGS_ARG, new ArrayList<>(hashtags)); + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true); + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle arguments = Objects.requireNonNull(getArguments()); + kind = Kind.valueOf(arguments.getString(KIND_ARG)); + if (kind == Kind.USER + || kind == Kind.USER_PINNED + || kind == Kind.USER_WITH_REPLIES + || kind == Kind.LIST) { + id = arguments.getString(ID_ARG); + } + if (kind == Kind.TAG) { + tags = arguments.getStringArrayList(HASHTAGS_ARG); + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true), + preferences.getBoolean("showCardsInTimelines", false) ? + CardViewMode.INDENTED : + CardViewMode.NONE, + preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + ); + adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this); + + isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); + + recyclerView = rootView.findViewById(R.id.recyclerView); + swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); + progressBar = rootView.findViewById(R.id.progressBar); + statusView = rootView.findViewById(R.id.statusView); + topProgressBar = rootView.findViewById(R.id.topProgressBar); + + setupSwipeRefreshLayout(); + setupRecyclerView(); + updateAdapter(); + setupTimelinePreferences(); + + if (statuses.isEmpty()) { + progressBar.setVisibility(View.VISIBLE); + bottomLoading = true; + this.sendInitialRequest(); + } else { + progressBar.setVisibility(View.GONE); + if (isNeedRefresh) + onRefresh(); + } + + return rootView; + } + + private void sendInitialRequest() { + if (this.kind == Kind.HOME) { + this.tryCache(); + } else { + sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1); + } + } + + private void tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, + TimelineRequestMode.DISK) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(statuses -> { + filterStatuses(statuses); + + if (statuses.size() > 1) { + this.clearPlaceholdersForResponse(statuses); + this.statuses.clear(); + this.statuses.addAll(statuses); + this.updateAdapter(); + this.progressBar.setVisibility(View.GONE); + // Request statuses including current top to refresh all of them + } + + this.updateCurrent(); + this.loadAbove(); + }); + } + + private void updateCurrent() { + if (this.statuses.isEmpty()) { + return; + } + + String topId = CollectionsKt.first(this.statuses, Either::isRight).asRight().getId(); + + this.timelineRepo.getStatuses(topId, null, null, LOAD_AT_ONCE, + TimelineRequestMode.NETWORK) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (statuses) -> { + this.initialUpdateFailed = false; + // When cached timeline is too old, we would replace it with nothing + if (!statuses.isEmpty()) { + filterStatuses(statuses); + + if (!this.statuses.isEmpty()) { + // clear old cached statuses + Iterator> iterator = this.statuses.iterator(); + while (iterator.hasNext()) { + Either item = iterator.next(); + if (item.isRight()) { + Status status = item.asRight(); + if (status.getId().length() < topId.length() || status.getId().compareTo(topId) < 0) { + + iterator.remove(); + } + } else { + Placeholder placeholder = item.asLeft(); + if (placeholder.getId().length() < topId.length() || placeholder.getId().compareTo(topId) < 0) { + + iterator.remove(); + } + } + + } + } + + this.statuses.addAll(statuses); + this.updateAdapter(); + } + this.bottomLoading = false; + + }, + (e) -> { + this.initialUpdateFailed = true; + // Indicate that we are not loading anymore + this.progressBar.setVisibility(View.GONE); + this.swipeRefreshLayout.setRefreshing(false); + }); + } + + private void setupTimelinePreferences() { + alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); + alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + boolean filter = preferences.getBoolean("tabFilterHomeReplies", true); + filterRemoveReplies = kind == Kind.HOME && !filter; + + filter = preferences.getBoolean("tabFilterHomeBoosts", true); + filterRemoveReblogs = kind == Kind.HOME && !filter; + + reloadFilters(preferences,false); + } + + private static boolean filterContextMatchesKind(Kind kind, List filterContext) { + // home, notifications, public, thread + switch (kind) { + case HOME: + case LIST: + return filterContext.contains(Filter.HOME); + case PUBLIC_FEDERATED: + case PUBLIC_LOCAL: + case TAG: + return filterContext.contains(Filter.PUBLIC); + case FAVOURITES: + return (filterContext.contains(Filter.PUBLIC) || filterContext.contains(Filter.NOTIFICATIONS)); + case USER: + case USER_WITH_REPLIES: + case USER_PINNED: + return filterContext.contains(Filter.ACCOUNT); + default: + return false; + } + } + + @Override + protected boolean filterIsRelevant(@NonNull Filter filter) { + return filterContextMatchesKind(kind, filter.getContext()); + } + + @Override + protected void refreshAfterApplyingFilters() { + fullyRefresh(); + } + + private void setupSwipeRefreshLayout() { + swipeRefreshLayout.setEnabled(isSwipeToRefreshEnabled); + if (isSwipeToRefreshEnabled) { + swipeRefreshLayout.setOnRefreshListener(this); + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); + } + } + + private void setupRecyclerView() { + recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull)); + Context context = recyclerView.getContext(); + recyclerView.setHasFixedSize(true); + layoutManager = new LinearLayoutManager(context); + recyclerView.setLayoutManager(layoutManager); + DividerItemDecoration divider = new DividerItemDecoration( + context, layoutManager.getOrientation()); + recyclerView.addItemDecoration(divider); + + // CWs are expanded without animation, buttons animate itself, we don't need it basically + ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + + recyclerView.setAdapter(adapter); + } + + private void deleteStatusById(String id) { + for (int i = 0; i < statuses.size(); i++) { + Either either = statuses.get(i); + if (either.isRight() + && id.equals(either.asRight().getId())) { + statuses.remove(either); + updateAdapter(); + break; + } + } + if (statuses.size() == 0) { + showNothing(); + } + } + + private void showNothing() { + statusView.setVisibility(View.VISIBLE); + statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't + * guaranteed to be set until then. */ + if (actionButtonPresent()) { + /* Use a modified scroll listener that both loads more statuses as it goes, and hides + * the follow button on down-scroll. */ + hideFab = preferences.getBoolean("fabHide", false); + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onScrolled(RecyclerView view, int dx, int dy) { + super.onScrolled(view, dx, dy); + + ActionButtonActivity activity = (ActionButtonActivity) getActivity(); + FloatingActionButton composeButton = activity.getActionButton(); + + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown()) { + composeButton.hide(); // hides the button if we're scrolling down + activity.onActionButtonHidden(); + } else if (dy < 0 && !composeButton.isShown()) { + composeButton.show(); // shows it if we are scrolling up + } + } else if (!composeButton.isShown()) { + composeButton.show(); + } + } + } + + @Override + public void onLoadMore(int totalItemsCount, RecyclerView view) { + TimelineFragment.this.onLoadMore(); + } + }; + } else { + // Just use the basic scroll listener to load more statuses. + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onLoadMore(int totalItemsCount, RecyclerView view) { + TimelineFragment.this.onLoadMore(); + } + }; + } + recyclerView.addOnScrollListener(scrollListener); + + if (!eventRegistered) { + eventHub.getEvents() + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(event -> { + if (event instanceof FavoriteEvent) { + FavoriteEvent favEvent = ((FavoriteEvent) event); + handleFavEvent(favEvent); + } else if (event instanceof ReblogEvent) { + ReblogEvent reblogEvent = (ReblogEvent) event; + handleReblogEvent(reblogEvent); + } else if (event instanceof BookmarkEvent) { + BookmarkEvent bookmarkEvent = (BookmarkEvent) event; + handleBookmarkEvent(bookmarkEvent); + } else if (event instanceof UnfollowEvent) { + if (kind == Kind.HOME) { + String id = ((UnfollowEvent) event).getAccountId(); + removeAllByAccountId(id); + } + } else if (event instanceof BlockEvent) { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + String id = ((BlockEvent) event).getAccountId(); + removeAllByAccountId(id); + } + } else if (event instanceof MuteConversationEvent) { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + handleMuteStatusEvent((MuteConversationEvent)event); + } + } else if (event instanceof MuteEvent) { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + handleMuteEvent((MuteEvent)event); + } + } else if (event instanceof DomainMuteEvent) { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + String instance = ((DomainMuteEvent) event).getInstance(); + removeAllByInstance(instance); + } + } else if (event instanceof StatusDeletedEvent) { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + String id = ((StatusDeletedEvent) event).getStatusId(); + deleteStatusById(id); + } + } else if (event instanceof StatusComposedEvent) { + Status status = ((StatusComposedEvent) event).getStatus(); + handleStatusComposeEvent(status); + } else if (event instanceof PreferenceChangedEvent) { + onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); + } else if (event instanceof EmojiReactEvent) { + handleEmojiReactEvent((EmojiReactEvent)event); + } + }); + eventRegistered = true; + } + } + + @Override + public void onRefresh() { + if (isSwipeToRefreshEnabled) + swipeRefreshLayout.setEnabled(true); + this.statusView.setVisibility(View.GONE); + isNeedRefresh = false; + if (this.initialUpdateFailed) { + updateCurrent(); + } + + this.loadAbove(); + + } + + private void loadAbove() { + String firstOrNull = null; + String secondOrNull = null; + for (int i = 0; i < this.statuses.size(); i++) { + Either status = this.statuses.get(i); + if (status.isRight()) { + firstOrNull = status.asRight().getId(); + if (i + 1 < statuses.size() && statuses.get(i + 1).isRight()) { + secondOrNull = statuses.get(i + 1).asRight().getId(); + } + break; + } + } + if (firstOrNull != null) { + this.sendFetchTimelineRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1); + } else { + this.sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1); + } + } + + @Override + public void onReply(int position) { + super.reply(statuses.get(position).asRight()); + } + + @Override + public void onReblog(final boolean reblog, final int position) { + final Status status = statuses.get(position).asRight(); + timelineCases.reblog(status, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (newStatus) -> setRebloggedForStatus(position, status, reblog), + (err) -> Log.d(TAG, "Failed to reblog status " + status.getId(), err) + ); + } + + private void setRebloggedForStatus(int position, Status status, boolean reblog) { + status.setReblogged(reblog); + + if (status.getReblog() != null) { + status.getReblog().setReblogged(reblog); + } + + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + + StatusViewData newViewData = + new StatusViewData.Builder(actual.first) + .setReblogged(reblog) + .createStatusViewData(); + statuses.setPairedItem(actual.second, newViewData); + updateAdapter(); + } + + @Override + public void onFavourite(final boolean favourite, final int position) { + final Status status = statuses.get(position).asRight(); + + timelineCases.favourite(status, favourite) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (newStatus) -> setFavouriteForStatus(position, newStatus, favourite), + (err) -> Log.d(TAG, "Failed to favourite status " + status.getId(), err) + ); + } + + private void setFavouriteForStatus(int position, Status status, boolean favourite) { + status.setFavourited(favourite); + + if (status.getReblog() != null) { + status.getReblog().setFavourited(favourite); + } + + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + + StatusViewData newViewData = new StatusViewData + .Builder(actual.first) + .setFavourited(favourite) + .createStatusViewData(); + statuses.setPairedItem(actual.second, newViewData); + updateAdapter(); + } + + @Override + public void onBookmark(final boolean bookmark, final int position) { + final Status status = statuses.get(position).asRight(); + + timelineCases.bookmark(status, bookmark) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (newStatus) -> setBookmarkForStatus(position, newStatus, bookmark), + (err) -> Log.d(TAG, "Failed to favourite status " + status.getId(), err) + ); + } + + private void setBookmarkForStatus(int position, Status status, boolean bookmark) { + status.setBookmarked(bookmark); + + if (status.getReblog() != null) { + status.getReblog().setBookmarked(bookmark); + } + + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + + StatusViewData newViewData = new StatusViewData + .Builder(actual.first) + .setBookmarked(bookmark) + .createStatusViewData(); + statuses.setPairedItem(actual.second, newViewData); + updateAdapter(); + } + + @Override + public void onMute(int position, boolean isMuted) { + StatusViewData.Concrete statusViewData = + new StatusViewData.Builder((StatusViewData.Concrete)statuses.getPairedItem(position)) + .setMuted(isMuted) + .createStatusViewData(); + statuses.setPairedItem(position, statusViewData); + updateAdapter(); + } + + private void setMutedStatusForStatus(int position, Status status, boolean muted, boolean threadMuted) { + status.setThreadMuted(threadMuted); + + StatusViewData.Builder statusViewData = new StatusViewData.Builder((StatusViewData.Concrete)statuses.getPairedItem(position)); + statusViewData.setMuted(muted); + statusViewData.setThreadMuted(threadMuted); + + statuses.setPairedItem(position, statusViewData.createStatusViewData()); + } + + public void onVoteInPoll(int position, @NonNull List choices) { + + final Status status = statuses.get(position).asRight(); + + Poll votedPoll = status.getActionableStatus().getPoll().votedCopy(choices); + + setVoteForPoll(position, status, votedPoll); + + timelineCases.voteInPoll(status, choices) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newPoll) -> setVoteForPoll(position, status, newPoll), + (t) -> Log.d(TAG, + "Failed to vote in poll: " + status.getId(), t) + ); + } + + private void setVoteForPoll(int position, Status status, Poll newPoll) { + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + + StatusViewData newViewData = new StatusViewData + .Builder(actual.first) + .setPoll(newPoll) + .createStatusViewData(); + statuses.setPairedItem(actual.second, newViewData); + updateAdapter(); + } + + @Override + public void onMore(@NonNull View view, final int position) { + super.more(statuses.get(position).asRight(), view, position); + } + + @Override + public void onOpenReblog(int position) { + super.openReblog(statuses.get(position).asRight()); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + StatusViewData newViewData = new StatusViewData.Builder( + ((StatusViewData.Concrete) statuses.getPairedItem(position))) + .setIsExpanded(expanded).createStatusViewData(); + statuses.setPairedItem(position, newViewData); + updateAdapter(); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + StatusViewData newViewData = new StatusViewData.Builder( + ((StatusViewData.Concrete) statuses.getPairedItem(position))) + .setIsShowingSensitiveContent(isShowing).createStatusViewData(); + statuses.setPairedItem(position, newViewData); + updateAdapter(); + } + + + @Override + public void onShowReblogs(int position) { + String statusId = statuses.get(position).asRight().getId(); + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + } + + @Override + public void onShowFavs(int position) { + String statusId = statuses.get(position).asRight().getId(); + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + } + + @Override + public void onLoadMore(int position) { + //check bounds before accessing list, + if (statuses.size() >= position && position > 0) { + Status fromStatus = statuses.get(position - 1).asRightOrNull(); + Status toStatus = statuses.get(position + 1).asRightOrNull(); + String maxMinusOne = + statuses.size() > position + 1 && statuses.get(position + 2).isRight() + ? statuses.get(position + 1).asRight().getId() + : null; + if (fromStatus == null || toStatus == null) { + Log.e(TAG, "Failed to load more at " + position + ", wrong placeholder position"); + return; + } + sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), maxMinusOne, + FetchEnd.MIDDLE, position); + + Placeholder placeholder = statuses.get(position).asLeft(); + StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.getId(), true); + statuses.setPairedItem(position, newViewData); + updateAdapter(); + } else { + Log.e(TAG, "error loading more"); + } + } + + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + if (position < 0 || position >= statuses.size()) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); + return; + } + + StatusViewData status = statuses.getPairedItem(position); + if (!(status instanceof StatusViewData.Concrete)) { + // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't + // check for null values when adding values to it although this doesn't seem to be an issue. + Log.e(TAG, String.format( + "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", + status == null ? "" : status.getClass().getSimpleName(), + position, + statuses.size() - 1 + )); + return; + } + + StatusViewData updatedStatus = new StatusViewData.Builder((StatusViewData.Concrete) status) + .setCollapsed(isCollapsed) + .createStatusViewData(); + statuses.setPairedItem(position, updatedStatus); + updateAdapter(); + } + + @Override + public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { + Status status = statuses.get(position).asRightOrNull(); + if (status == null) return; + super.viewMedia(attachmentIndex, status, view); + } + + @Override + public void onViewThread(int position) { + super.viewThread(statuses.get(position).asRight()); + } + + @Override + public void onViewReplyTo(int position) { + Status status = statuses.get(position).asRightOrNull(); + if (status == null) return; + + String replyToId = status.getReblog() == null ? status.getInReplyToId() : status.getReblog().getInReplyToId(); + if (replyToId == null) return; + super.onShowReplyTo(replyToId); + } + + @Override + public void onViewTag(String tag) { + if (kind == Kind.TAG && tags.size() == 1 && tags.contains(tag)) { + // If already viewing a tag page, then ignore any request to view that tag again. + return; + } + super.viewTag(tag); + } + + @Override + public void onViewAccount(String id) { + if ((kind == Kind.USER || kind == Kind.USER_WITH_REPLIES) && this.id.equals(id)) { + /* If already viewing an account page, then any requests to view that account page + * should be ignored. */ + return; + } + super.viewAccount(id); + } + + private void onPreferenceChanged(String key) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + switch (key) { + case "fabHide": { + hideFab = sharedPreferences.getBoolean("fabHide", false); + break; + } + case "mediaPreviewEnabled": { + boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); + boolean oldMediaPreviewEnabled = adapter.getMediaPreviewEnabled(); + if (enabled != oldMediaPreviewEnabled) { + adapter.setMediaPreviewEnabled(enabled); + fullyRefresh(); + } + break; + } + case "tabFilterHomeReplies": { + boolean filter = sharedPreferences.getBoolean("tabFilterHomeReplies", true); + boolean oldRemoveReplies = filterRemoveReplies; + filterRemoveReplies = kind == Kind.HOME && !filter; + if (adapter.getItemCount() > 1 && oldRemoveReplies != filterRemoveReplies) { + fullyRefresh(); + } + break; + } + case "tabFilterHomeBoosts": { + boolean filter = sharedPreferences.getBoolean("tabFilterHomeBoosts", true); + boolean oldRemoveReblogs = filterRemoveReblogs; + filterRemoveReblogs = kind == Kind.HOME && !filter; + if (adapter.getItemCount() > 1 && oldRemoveReblogs != filterRemoveReblogs) { + fullyRefresh(); + } + break; + } + case PrefKeys.HIDE_MUTED_USERS: { + updateMuteFilter(sharedPreferences, true); + break; + } + case Filter.HOME: + case Filter.NOTIFICATIONS: + case Filter.THREAD: + case Filter.PUBLIC: + case Filter.ACCOUNT: { + if (filterContextMatchesKind(kind, Collections.singletonList(key))) { + reloadFilters(sharedPreferences, true); + } + break; + } + case "alwaysShowSensitiveMedia": { + //it is ok if only newly loaded statuses are affected, no need to fully refresh + alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); + break; + } + } + } + + @Override + public void removeItem(int position) { + statuses.remove(position); + updateAdapter(); + } + + private void removeAllByConversationId(int conversationId) { + // using iterator to safely remove items while iterating + Iterator> iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status status = iterator.next().asRightOrNull(); + if (status != null && + (status.getConversationId() == conversationId) || status.getActionableStatus().getConversationId() == conversationId) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void removeAllByAccountId(String accountId) { + // using iterator to safely remove items while iterating + Iterator> iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status status = iterator.next().asRightOrNull(); + if (status != null && + (status.getAccount().getId().equals(accountId) || status.getActionableStatus().getAccount().getId().equals(accountId))) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void removeAllByInstance(String instance) { + // using iterator to safely remove items while iterating + Iterator> iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status status = iterator.next().asRightOrNull(); + if (status != null && LinkHelper.getDomain(status.getAccount().getUrl()).equals(instance)) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void onLoadMore() { + if (didLoadEverythingBottom || bottomLoading) { + return; + } + + if (statuses.size() == 0) { + sendInitialRequest(); + return; + } + + bottomLoading = true; + + Either last = statuses.get(statuses.size() - 1); + Placeholder placeholder; + if (last.isRight()) { + final String placeholderId = StringUtils.dec(last.asRight().getId()); + placeholder = new Placeholder(placeholderId); + statuses.add(new Either.Left<>(placeholder)); + } else { + placeholder = last.asLeft(); + } + statuses.setPairedItem(statuses.size() - 1, + new StatusViewData.Placeholder(placeholder.getId(), true)); + + updateAdapter(); + + String bottomId = null; + if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { + bottomId = this.nextId; + } else { + final ListIterator> iterator = + this.statuses.listIterator(this.statuses.size()); + while (iterator.hasPrevious()) { + Either previous = iterator.previous(); + if (previous.isRight()) { + bottomId = previous.asRight().getId(); + break; + } + } + } + sendFetchTimelineRequest(bottomId, null, null, FetchEnd.BOTTOM, -1); + } + + private void fullyRefresh() { + statuses.clear(); + updateAdapter(); + bottomLoading = true; + sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1); + } + + private boolean actionButtonPresent() { + return kind != Kind.TAG && kind != Kind.FAVOURITES && kind != Kind.BOOKMARKS && + getActivity() instanceof ActionButtonActivity; + } + + private void jumpToTop() { + if (isAdded()) { + layoutManager.scrollToPosition(0); + recyclerView.stopScroll(); + scrollListener.reset(); + } + } + + private Call> getFetchCallByTimelineType(String fromId, String uptoId) { + MastodonApi api = mastodonApi; + switch (kind) { + default: + case HOME: + return api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE); + case PUBLIC_FEDERATED: + return api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE); + case PUBLIC_LOCAL: + return api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE); + case TAG: + String firstHashtag = tags.get(0); + List additionalHashtags = tags.subList(1, tags.size()); + return api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE); + case USER: + return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, true, null, null); + case USER_PINNED: + return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, true); + case USER_WITH_REPLIES: + return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, null); + case FAVOURITES: + return api.favourites(fromId, uptoId, LOAD_AT_ONCE); + case BOOKMARKS: + return api.bookmarks(fromId, uptoId, LOAD_AT_ONCE); + case LIST: + return api.listTimeline(id, fromId, uptoId, LOAD_AT_ONCE); + } + } + + private void sendFetchTimelineRequest(@Nullable String maxId, @Nullable String sinceId, + @Nullable String sinceIdMinusOne, + final FetchEnd fetchEnd, final int pos) { + if (isAdded() && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && progressBar.getVisibility() != View.VISIBLE) && !isSwipeToRefreshEnabled) + topProgressBar.show(); + + if (kind == Kind.HOME) { + TimelineRequestMode mode; + // allow getting old statuses/fallbacks for network only for for bottom loading + if (fetchEnd == FetchEnd.BOTTOM) { + mode = TimelineRequestMode.ANY; + } else { + mode = TimelineRequestMode.NETWORK; + } + timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (result) -> onFetchTimelineSuccess(result, fetchEnd, pos), + (err) -> onFetchTimelineFailure(new Exception(err), fetchEnd, pos) + ); + } else { + Callback> callback = new Callback>() { + @Override + public void onResponse(@NonNull Call> call, @NonNull Response> response) { + if (response.isSuccessful()) { + @Nullable + String newNextId = extractNextId(response); + if (newNextId != null) { + // when we reach the bottom of the list, we won't have a new link. If + // we blindly write `null` here we will start loading from the top + // again. + nextId = newNextId; + } + onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos); + } else { + onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos); + } + } + + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + onFetchTimelineFailure((Exception) t, fetchEnd, pos); + } + }; + + Call> listCall = getFetchCallByTimelineType(maxId, sinceId); + callList.add(listCall); + listCall.enqueue(callback); + } + } + + @Nullable + private String extractNextId(Response response) { + String linkHeader = response.headers().get("Link"); + if (linkHeader == null) { + return null; + } + List links = HttpHeaderLink.parse(linkHeader); + HttpHeaderLink nextHeader = HttpHeaderLink.findByRelationType(links, "next"); + if (nextHeader == null) { + return null; + } + Uri nextLink = nextHeader.uri; + if (nextLink == null) { + return null; + } + return nextLink.getQueryParameter("max_id"); + } + + private void onFetchTimelineSuccess(List> statuses, + FetchEnd fetchEnd, int pos) { + + // We filled the hole (or reached the end) if the server returned less statuses than we + // we asked for. + boolean fullFetch = statuses.size() >= LOAD_AT_ONCE; + filterStatuses(statuses); + switch (fetchEnd) { + case TOP: { + updateStatuses(statuses, fullFetch); + break; + } + case MIDDLE: { + replacePlaceholderWithStatuses(statuses, fullFetch, pos); + break; + } + case BOTTOM: { + if (!this.statuses.isEmpty() + && !this.statuses.get(this.statuses.size() - 1).isRight()) { + this.statuses.remove(this.statuses.size() - 1); + updateAdapter(); + } + + if (!statuses.isEmpty() && !statuses.get(statuses.size() - 1).isRight()) { + // Removing placeholder if it's the last one from the cache + statuses.remove(statuses.size() - 1); + } + int oldSize = this.statuses.size(); + if (this.statuses.size() > 1) { + addItems(statuses); + } else { + updateStatuses(statuses, fullFetch); + } + if (this.statuses.size() == oldSize) { + // This may be a brittle check but seems like it works + // Can we check it using headers somehow? Do all server support them? + didLoadEverythingBottom = true; + } + break; + } + } + if (isAdded()) { + topProgressBar.hide(); + updateBottomLoadingState(fetchEnd); + progressBar.setVisibility(View.GONE); + swipeRefreshLayout.setRefreshing(false); + swipeRefreshLayout.setEnabled(true); + if (this.statuses.size() == 0) { + this.showNothing(); + } else { + this.statusView.setVisibility(View.GONE); + } + } + } + + private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) { + if (isAdded()) { + swipeRefreshLayout.setRefreshing(false); + topProgressBar.hide(); + + if (fetchEnd == FetchEnd.MIDDLE && !statuses.get(position).isRight()) { + Placeholder placeholder = statuses.get(position).asLeftOrNull(); + StatusViewData newViewData; + if (placeholder == null) { + Status above = statuses.get(position - 1).asRight(); + String newId = StringUtils.dec(above.getId()); + placeholder = new Placeholder(newId); + } + newViewData = new StatusViewData.Placeholder(placeholder.getId(), false); + statuses.setPairedItem(position, newViewData); + updateAdapter(); + } else if (this.statuses.isEmpty()) { + swipeRefreshLayout.setEnabled(false); + this.statusView.setVisibility(View.VISIBLE); + if (exception instanceof IOException) { + this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { + this.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } else { + this.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { + this.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } + } + + Log.e(TAG, "Fetch Failure: " + exception.getMessage()); + updateBottomLoadingState(fetchEnd); + progressBar.setVisibility(View.GONE); + } + } + + private void updateBottomLoadingState(FetchEnd fetchEnd) { + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + } + + private void filterStatuses(List> statuses) { + Iterator> it = statuses.iterator(); + while (it.hasNext()) { + Status status = it.next().asRightOrNull(); + if (status != null + && ((status.getInReplyToId() != null && filterRemoveReplies) + || (status.getReblog() != null && filterRemoveReblogs) + || shouldFilterStatus(status.getActionableStatus()))) { + it.remove(); + } + } + } + + private void updateStatuses(List> newStatuses, boolean fullFetch) { + if (ListUtils.isEmpty(newStatuses)) { + updateAdapter(); + return; + } + + if (statuses.isEmpty()) { + statuses.addAll(newStatuses); + } else { + Either lastOfNew = newStatuses.get(newStatuses.size() - 1); + int index = statuses.indexOf(lastOfNew); + + if (index >= 0) { + statuses.subList(0, index).clear(); + } + + int newIndex = newStatuses.indexOf(statuses.get(0)); + if (newIndex == -1) { + if (index == -1 && fullFetch) { + String placeholderId = StringUtils.inc( + CollectionsKt.last(newStatuses, Either::isRight).asRight().getId()); + newStatuses.add(new Either.Left<>(new Placeholder(placeholderId))); + } + statuses.addAll(0, newStatuses); + } else { + statuses.addAll(0, newStatuses.subList(0, newIndex)); + } + } + // Remove all consecutive placeholders + removeConsecutivePlaceholders(); + updateAdapter(); + } + + private void removeConsecutivePlaceholders() { + for (int i = 0; i < statuses.size() - 1; i++) { + if (statuses.get(i).isLeft() && statuses.get(i + 1).isLeft()) { + statuses.remove(i); + } + } + } + + private void addItems(List> newStatuses) { + if (ListUtils.isEmpty(newStatuses)) { + return; + } + Either last = null; + for (int i = statuses.size() - 1; i >= 0; i--) { + if (statuses.get(i).isRight()) { + last = statuses.get(i); + break; + } + } + // I was about to replace findStatus with indexOf but it is incorrect to compare value + // types by ID anyway and we should change equals() for Status, I think, so this makes sense + if (last != null && !newStatuses.contains(last)) { + statuses.addAll(newStatuses); + removeConsecutivePlaceholders(); + updateAdapter(); + } + } + + /** + * For certain requests we don't want to see placeholders, they will be removed some other way + */ + private void clearPlaceholdersForResponse(List> statuses) { + CollectionsKt.removeAll(statuses, Either::isLeft); + } + + private void replacePlaceholderWithStatuses(List> newStatuses, + boolean fullFetch, int pos) { + Either placeholder = statuses.get(pos); + if (placeholder.isLeft()) { + statuses.remove(pos); + } + + if (ListUtils.isEmpty(newStatuses)) { + updateAdapter(); + return; + } + + if (fullFetch) { + newStatuses.add(placeholder); + } + + statuses.addAll(pos, newStatuses); + removeConsecutivePlaceholders(); + + updateAdapter(); + + } + + private int findStatusOrReblogPositionById(@NonNull String statusId) { + for (int i = 0; i < statuses.size(); i++) { + Status status = statuses.get(i).asRightOrNull(); + if (status != null + && (statusId.equals(status.getId()) + || (status.getReblog() != null + && statusId.equals(status.getReblog().getId())))) { + return i; + } + } + return -1; + } + + private final Function1> statusLifter = + Either.Right::new; + + @Nullable + private Pair + findStatusAndPosition(int position, Status status) { + StatusViewData.Concrete statusToUpdate; + int positionToUpdate; + StatusViewData someOldViewData = statuses.getPairedItem(position); + + // Unlikely, but data could change between the request and response + if ((someOldViewData instanceof StatusViewData.Placeholder) || + !((StatusViewData.Concrete) someOldViewData).getId().equals(status.getId())) { + // try to find the status we need to update + int foundPos = statuses.indexOf(new Either.Right<>(status)); + if (foundPos < 0) return null; // okay, it's hopeless, give up + statusToUpdate = ((StatusViewData.Concrete) + statuses.getPairedItem(foundPos)); + positionToUpdate = position; + } else { + statusToUpdate = (StatusViewData.Concrete) someOldViewData; + positionToUpdate = position; + } + return new Pair<>(statusToUpdate, positionToUpdate); + } + + private void handleReblogEvent(@NonNull ReblogEvent reblogEvent) { + int pos = findStatusOrReblogPositionById(reblogEvent.getStatusId()); + if (pos < 0) return; + Status status = statuses.get(pos).asRight(); + setRebloggedForStatus(pos, status, reblogEvent.getReblog()); + } + + private void handleFavEvent(@NonNull FavoriteEvent favEvent) { + int pos = findStatusOrReblogPositionById(favEvent.getStatusId()); + if (pos < 0) return; + Status status = statuses.get(pos).asRight(); + setFavouriteForStatus(pos, status, favEvent.getFavourite()); + } + + private void handleBookmarkEvent(@NonNull BookmarkEvent bookmarkEvent) { + int pos = findStatusOrReblogPositionById(bookmarkEvent.getStatusId()); + if (pos < 0) return; + Status status = statuses.get(pos).asRight(); + setBookmarkForStatus(pos, status, bookmarkEvent.getBookmark()); + } + + private void handleStatusComposeEvent(@NonNull Status status) { + switch (kind) { + case HOME: + case PUBLIC_FEDERATED: + case PUBLIC_LOCAL: + break; + case USER: + case USER_WITH_REPLIES: + if (status.getAccount().getId().equals(id)) { + break; + } else { + return; + } + case TAG: + case FAVOURITES: + case LIST: + return; + } + onRefresh(); + } + + private void handleMuteStatusEvent(MuteConversationEvent event) { + int pos = findStatusOrReblogPositionById(event.getStatusId()); + + if (pos < 0) + return; + + Status eventStatus = statuses.get(pos).asRight(); + int conversationId = eventStatus.getConversationId(); + + if(conversationId == -1) { // invalid conversation ID + if(isFilteringMuted()) { + statuses.remove(pos); + } else { + setMutedStatusForStatus(pos, eventStatus, event.getMute(), event.getMute()); + } + updateAdapter(); + } else { + //noinspection ConstantConditions + if(isFilteringMuted()) { + removeAllByConversationId(conversationId); + } else { + for (int i = 0; i < statuses.size(); i++) { + Status status = statuses.get(i).asRightOrNull(); + if (status != null && status.getConversationId() == conversationId) { + setMutedStatusForStatus(i, status, event.getMute(), event.getMute()); + } + } + updateAdapter(); + } + } + } + + private void handleMuteEvent(MuteEvent event) { + String id = event.getAccountId(); + boolean muting = event.getMute(); + + if(isFilteringMuted() && muting) { + removeAllByAccountId(id); + } else { + for (int i = 0; i < statuses.size(); i++) { + Status status = statuses.get(i).asRightOrNull(); + if (status != null + && status.getAccount().getId().equals(id) + && !status.isThreadMuted()) { + setMutedStatusForStatus(i, status, muting, false); + } + } + updateAdapter(); + } + } + + private List> liftStatusList(List list) { + return CollectionsKt.map(list, statusLifter); + } + + private void updateAdapter() { + differ.submitList(statuses.getPairedCopy()); + } + + private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { + @Override + public void onInserted(int position, int count) { + if (isAdded()) { + adapter.notifyItemRangeInserted(position, count); + Context context = getContext(); + // scroll up when new items at the top are loaded while being in the first position + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.getItemCount() != count) { + if (isSwipeToRefreshEnabled) + recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); + else + recyclerView.scrollToPosition(0); + } + } + } + + @Override + public void onRemoved(int position, int count) { + adapter.notifyItemRangeRemoved(position, count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + adapter.notifyItemMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, Object payload) { + adapter.notifyItemRangeChanged(position, count, payload); + } + }; + + + private final AsyncListDiffer + differ = new AsyncListDiffer<>(listUpdateCallback, + new AsyncDifferConfig.Builder<>(diffCallback).build()); + + private final TimelineAdapter.AdapterDataSource dataSource = + new TimelineAdapter.AdapterDataSource() { + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public StatusViewData getItemAt(int pos) { + return differ.getCurrentList().get(pos); + } + }; + + private static final DiffUtil.ItemCallback diffCallback + = new DiffUtil.ItemCallback() { + + @Override + public boolean areItemsTheSame(StatusViewData oldItem, StatusViewData newItem) { + return oldItem.getViewDataId() == newItem.getViewDataId(); + } + + @Override + public boolean areContentsTheSame(StatusViewData oldItem, @NonNull StatusViewData newItem) { + return false; //Items are different always. It allows to refresh timestamp on every view holder update + } + + @Nullable + @Override + public Object getChangePayload(@NonNull StatusViewData oldItem, @NonNull StatusViewData newItem) { + if (oldItem.deepEquals(newItem)) { + //If items are equal - update timestamp only + return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); + } else + // If items are different - update a whole view holder + return null; + } + }; + + @Override + public void onResume() { + super.onResume(); + startUpdateTimestamp(); + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private void startUpdateTimestamp() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) + .subscribe( + interval -> updateAdapter() + ); + } + + } + + @Override + public void onReselect() { + jumpToTop(); + } + + @Override + public void refreshContent() { + if (isAdded()) + onRefresh(); + else + isNeedRefresh = true; + } + + private void setEmojiReactionForStatus(int position, Status newStatus) { + StatusViewData newViewData = ViewDataUtils.statusToViewData(newStatus, false, false); + statuses.setPairedItem(position, newViewData); + updateAdapter(); + } + + private void setEmojiReactForStatus(int position, Status status, Status newStatus) { + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + + setEmojiReactionForStatus(actual.second, newStatus); + } + + public void handleEmojiReactEvent(EmojiReactEvent event) { + int pos = findStatusOrReblogPositionById(event.getNewStatus().getActionableId()); + if (pos < 0) return; + Status status = statuses.get(pos).asRight(); + setEmojiReactForStatus(pos, status, event.getNewStatus()); + } + + @Override + public void onEmojiReact(final boolean react, final String emoji, final String statusId) { + int position = findStatusOrReblogPositionById(statusId); + if (position < 0) return; + + timelineCases.react(emoji, statusId, react) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setEmojiReactionForStatus(position, newStatus), + (t) -> Log.d(TAG, + "Failed to react with " + emoji + " on status: " + statusId, t) + ); + + } + + + @Override + public void onEmojiReactMenu(@NonNull View view, final EmojiReaction emoji, final String statusId) { + super.emojiReactMenu(statusId, emoji, view, this); + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt new file mode 100644 index 0000000..f7d8538 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -0,0 +1,288 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.view.* +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.github.piasy.biv.BigImageViewer +import com.github.piasy.biv.loader.ImageLoader +import com.github.piasy.biv.view.GlideImageViewFactory +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.visible +import io.reactivex.subjects.BehaviorSubject +import kotlinx.android.synthetic.main.activity_view_media.* +import kotlinx.android.synthetic.main.fragment_view_image.* +import java.io.File +import java.lang.Exception +import kotlin.math.abs +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.github.piasy.biv.view.BigImageView + + +class ViewImageFragment : ViewMediaFragment() { + interface PhotoActionsListener { + fun onBringUp() + fun onDismiss() + fun onPhotoTap() + } + + private lateinit var photoActionsListener: PhotoActionsListener + private lateinit var toolbar: View + private var shouldStartTransition = false + + // Volatile: Image requests happen on background thread and we want to see updates to it + // immediately on another thread. Atomic is an overkill for such thing. + @Volatile + private var startedTransition = false + + private var uri = Uri.EMPTY + private var previewUri = Uri.EMPTY + private var showingPreview = false + + override fun onAttach(context: Context) { + super.onAttach(context) + photoActionsListener = context as PhotoActionsListener + } + + override fun setupMediaView(url: String, + previewUrl: String?, + description: String?, + showingDescription: Boolean) { + photoView.transitionName = url + mediaDescription.text = description + captionSheet.visible(showingDescription) + + startedTransition = false + uri = Uri.parse(url) + if(previewUrl != null && !previewUrl.equals(url)) { + previewUri = Uri.parse(previewUrl) + } + loadImageFromNetwork() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + toolbar = activity!!.toolbar + return inflater.inflate(R.layout.fragment_view_image, container, false) + } + + private val imageOnTouchListener = object : View.OnTouchListener { + private var lastY = 0.0f + private var swipeStartedWithOneFinger = false + + override fun onTouch(v: View, event: MotionEvent): Boolean { + // This part is for scaling/translating on vertical move. + // We use raw coordinates to get the correct ones during scaling + + if(event.pointerCount != 1) { + onGestureEnd() + swipeStartedWithOneFinger = false + return false + } + + when(event.action) { + MotionEvent.ACTION_DOWN -> { + swipeStartedWithOneFinger = true + lastY = event.rawY + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + onGestureEnd() + swipeStartedWithOneFinger = false + } + MotionEvent.ACTION_MOVE -> { + if(swipeStartedWithOneFinger && + (photoView.ssiv == null || photoView.ssiv.scale <= photoView.ssiv.minScale)) { + val diff = event.rawY - lastY + // This code is to prevent transformations during page scrolling + // If we are already translating or we reached the threshold, then transform. + if (photoView.translationY != 0f || abs(diff) > 40) { + photoView.translationY += (diff) + val scale = (-abs(photoView.translationY) / 720 + 1).coerceAtLeast(0.5f) + photoView.scaleY = scale + photoView.scaleX = scale + lastY = event.rawY + } + } + } + } + + return false + } + } + + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + photoView.setImageLoaderCallback(imageLoaderCallback) + photoView.setImageViewFactory(GlideImageViewFactory()) + + val arguments = this.requireArguments() + val attachment = arguments.getParcelable(ARG_ATTACHMENT) + this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) + val url: String? + var description: String? = null + + if (attachment != null) { + url = attachment.url + description = attachment.description + } else { + url = arguments.getString(ARG_AVATAR_URL) + if (url == null) { + throw IllegalArgumentException("attachment or avatar url has to be set") + } + } + + finalizeViewSetup(url, attachment?.previewUrl, description) + } + + private fun onGestureEnd() { + if (abs(photoView.translationY) > 180) { + photoActionsListener.onDismiss() + } else { + photoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() + } + } + + private fun onMediaTap() { + photoActionsListener.onPhotoTap() + } + + override fun onToolbarVisibilityChange(visible: Boolean) { + if (photoView == null || !userVisibleHint || captionSheet == null) { + return + } + isDescriptionVisible = showingDescription && visible + val alpha = if (isDescriptionVisible) 1.0f else 0.0f + captionSheet.animate().alpha(alpha) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + captionSheet?.visible(isDescriptionVisible) + animation.removeListener(this) + } + }) + .start() + } + + override fun onDestroyView() { + super.onDestroyView() + photoView.ssiv?.recycle() + } + + private inner class DummyCacheTarget(val ctx: Context, val requestPreview : Boolean) : CustomTarget() { + override fun onLoadCleared(placeholder: Drawable?) {} + override fun onLoadFailed(errorDrawable: Drawable?) { + if(requestPreview) { + // no preview, no full image in cache, load full image + // forget about fancy transition + showingPreview = false + photoView.showImage(uri) + photoActionsListener.onBringUp() + } else { + // let's start downloading full image that we supposedly don't have + BigImageViewer.prefetch(uri) + + // meanwhile poke cache about preview image + Glide.with(ctx).asFile() + .load(previewUri) + .dontAnimate() + .onlyRetrieveFromCache(true) + .into(DummyCacheTarget(ctx, true)) + } + } + + override fun onResourceReady(resource: File, transition: Transition?) { + showingPreview = requestPreview + if(requestPreview) { + // have preview cached but not full image + photoView.showImage(previewUri, uri, true) + } else { + photoView.showImage(uri) + } + photoActionsListener.onBringUp() + } + } + + private fun loadImageFromNetwork() { + if(previewUri != Uri.EMPTY) { + // check if we have full image in the cache, if yes, use it + // if not, look for preview in cache and use it if available + // if not, load full image anyway + Glide.with(this).asFile() + .load(uri) + .onlyRetrieveFromCache(true) + .dontAnimate() + .into(DummyCacheTarget(context!!, false)) + } else { + // no need in cache lookup, just load full image + showingPreview = false + photoView.showImage(uri) + photoActionsListener.onBringUp() + } + } + + override fun onTransitionEnd() { + // if we had preview, load full image, as transition has ended + if (showingPreview) { + showingPreview = false + photoView.showImage(uri) + } + } + + private val imageLoaderCallback = object : ImageLoader.Callback { + override fun onSuccess(image: File?) { + if(!showingPreview) { + progressBar?.hide() + + photoView.setInitScaleType(BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE) + photoView.ssiv?.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF + photoView.mainView?.setOnTouchListener(imageOnTouchListener) + } + } + + override fun onFail(error: Exception?) { + progressBar?.hide() + } + + override fun onCacheHit(imageType: Int, image: File?) { + } + + override fun onStart() { + } + + override fun onCacheMiss(imageType: Int, image: File?) { + // this callback is useless because it's called after + // image is downloaded or pulled from cache + // so in case of cache miss, onStart is used + } + + override fun onFinish() {} + override fun onProgress(progress: Int) { + // TODO: make use of it :) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt new file mode 100644 index 0000000..9b51f28 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -0,0 +1,95 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment + +import android.os.Bundle +import android.text.TextUtils +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.entity.Attachment + +abstract class ViewMediaFragment : BaseFragment() { + private var toolbarVisibiltyDisposable: Function0? = null + + abstract fun setupMediaView( + url: String, + previewUrl: String?, + description: String?, + showingDescription: Boolean + ) + + abstract fun onToolbarVisibilityChange(visible: Boolean) + + protected var showingDescription = false + protected var isDescriptionVisible = false + + companion object { + @JvmStatic + protected val ARG_START_POSTPONED_TRANSITION = "startPostponedTransition" + + @JvmStatic + protected val ARG_ATTACHMENT = "attach" + @JvmStatic + protected val ARG_AVATAR_URL = "avatarUrl" + + @JvmStatic + fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment { + val arguments = Bundle(2) + arguments.putParcelable(ARG_ATTACHMENT, attachment) + arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, shouldStartPostponedTransition) + + val fragment = when (attachment.type) { + Attachment.Type.IMAGE -> ViewImageFragment() + Attachment.Type.VIDEO, + Attachment.Type.GIFV, + Attachment.Type.AUDIO -> ViewVideoFragment() + else -> ViewImageFragment() // it probably won't show anything, but its better than crashing + } + fragment.arguments = arguments + return fragment + } + + @JvmStatic + fun newAvatarInstance(avatarUrl: String): ViewMediaFragment { + val arguments = Bundle(2) + val fragment = ViewImageFragment() + arguments.putString(ARG_AVATAR_URL, avatarUrl) + arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, true) + + fragment.arguments = arguments + return fragment + } + } + + abstract fun onTransitionEnd() + + protected fun finalizeViewSetup(url: String, previewUrl: String?, description: String?) { + val mediaActivity = activity as ViewMediaActivity + + showingDescription = !TextUtils.isEmpty(description) + isDescriptionVisible = showingDescription + setupMediaView(url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible) + + toolbarVisibiltyDisposable = (activity as ViewMediaActivity) + .addToolbarVisibilityListener { isVisible -> + onToolbarVisibilityChange(isVisible) + } + } + + override fun onDestroyView() { + toolbarVisibiltyDisposable?.invoke() + super.onDestroyView() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java new file mode 100644 index 0000000..0dcc931 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -0,0 +1,821 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.arch.core.util.Function; +import androidx.core.util.Pair; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.snackbar.Snackbar; +import com.keylesspalace.tusky.AccountListActivity; +import com.keylesspalace.tusky.BaseActivity; +import com.keylesspalace.tusky.BuildConfig; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.ViewThreadActivity; +import com.keylesspalace.tusky.adapter.ThreadAdapter; +import com.keylesspalace.tusky.appstore.*; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.entity.*; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.network.MastodonApi; +import com.keylesspalace.tusky.settings.PrefKeys; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; +import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.ViewDataUtils; +import com.keylesspalace.tusky.view.ConversationLineItemDecoration; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; + +import javax.inject.Inject; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static com.uber.autodispose.AutoDispose.autoDisposable; +import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; + +public final class ViewThreadFragment extends SFragment implements + SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable { + private static final String TAG = "ViewThreadFragment"; + + @Inject + public MastodonApi mastodonApi; + @Inject + public EventHub eventHub; + + private SwipeRefreshLayout swipeRefreshLayout; + private RecyclerView recyclerView; + private ThreadAdapter adapter; + private String thisThreadsStatusId; + private boolean alwaysShowSensitiveMedia; + private boolean alwaysOpenSpoiler; + + private int statusIndex = 0; + + private final PairedList statuses = + new PairedList<>(new Function() { + @Override + public StatusViewData.Concrete apply(Status input) { + return ViewDataUtils.statusToViewData( + input, + alwaysShowSensitiveMedia, + alwaysOpenSpoiler + ); + } + }); + + public static ViewThreadFragment newInstance(String id) { + Bundle arguments = new Bundle(1); + ViewThreadFragment fragment = new ViewThreadFragment(); + arguments.putString("id", id); + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + thisThreadsStatusId = getArguments().getString("id"); + SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(getActivity()); + + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true), + preferences.getBoolean("showCardsInTimelines", false) ? + CardViewMode.INDENTED : + CardViewMode.NONE, + preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) + ); + adapter = new ThreadAdapter(statusDisplayOptions, this); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false); + + Context context = getContext(); + swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); + swipeRefreshLayout.setOnRefreshListener(this); + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); + + recyclerView = rootView.findViewById(R.id.recyclerView); + recyclerView.setHasFixedSize(true); + LinearLayoutManager layoutManager = new LinearLayoutManager(context); + recyclerView.setLayoutManager(layoutManager); + recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull)); + DividerItemDecoration divider = new DividerItemDecoration( + context, layoutManager.getOrientation()); + recyclerView.addItemDecoration(divider); + + recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); + alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); + alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); + reloadFilters(PreferenceManager.getDefaultSharedPreferences(context), false); + + recyclerView.setAdapter(adapter); + + statuses.clear(); + + ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + + return rootView; + } + + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + onRefresh(); + + eventHub.getEvents() + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(event -> { + if (event instanceof FavoriteEvent) { + handleFavEvent((FavoriteEvent) event); + } else if (event instanceof ReblogEvent) { + handleReblogEvent((ReblogEvent) event); + } else if (event instanceof BookmarkEvent) { + handleBookmarkEvent((BookmarkEvent) event); + } else if (event instanceof BlockEvent) { + removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof MuteEvent) { + handleMuteEvent((MuteEvent) event); + } else if (event instanceof StatusComposedEvent) { + handleStatusComposedEvent((StatusComposedEvent) event); + } else if (event instanceof StatusDeletedEvent) { + handleStatusDeletedEvent((StatusDeletedEvent) event); + } else if (event instanceof EmojiReactEvent) { + handleEmojiReactEvent((EmojiReactEvent)event); + } + }); + + if(thisThreadsStatusPosition != -1) { + recyclerView.scrollToPosition(thisThreadsStatusPosition); + } + } + + public void onRevealPressed() { + boolean allExpanded = allExpanded(); + for (int i = 0; i < statuses.size(); i++) { + StatusViewData.Concrete newViewData = + new StatusViewData.Concrete.Builder(statuses.getPairedItem(i)) + .setIsExpanded(!allExpanded) + .createStatusViewData(); + statuses.setPairedItem(i, newViewData); + } + updateAdapter(); + updateRevealIcon(); + } + + private boolean allExpanded() { + boolean allExpanded = true; + for (int i = 0; i < statuses.size(); i++) { + if (!statuses.getPairedItem(i).isExpanded()) { + allExpanded = false; + break; + } + } + return allExpanded; + } + + @Override + public void onRefresh() { + sendStatusRequest(thisThreadsStatusId); + sendThreadRequest(thisThreadsStatusId); + } + + @Override + public void onReply(int position) { + super.reply(statuses.get(position)); + } + + @Override + public void onReblog(final boolean reblog, final int position) { + final Status status = statuses.get(position); + + timelineCases.reblog(statuses.get(position), reblog) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> updateStatus(position, newStatus), + (t) -> Log.d(TAG, + "Failed to reblog status: " + status.getId(), t) + ); + } + + @Override + public void onFavourite(final boolean favourite, final int position) { + final Status status = statuses.get(position); + + timelineCases.favourite(statuses.get(position), favourite) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> updateStatus(position, newStatus), + (t) -> Log.d(TAG, + "Failed to favourite status: " + status.getId(), t) + ); + } + + @Override + public void onBookmark(final boolean bookmark, final int position) { + final Status status = statuses.get(position); + + timelineCases.bookmark(statuses.get(position), bookmark) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> updateStatus(position, newStatus), + (t) -> Log.d(TAG, + "Failed to bookmark status: " + status.getId(), t) + ); + } + + private void updateStatus(int position, Status status) { + if (position >= 0 && position < statuses.size()) { + + Status actionableStatus = status.getActionableStatus(); + + StatusViewData.Concrete viewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setReblogged(actionableStatus.getReblogged()) + .setReblogsCount(actionableStatus.getReblogsCount()) + .setFavourited(actionableStatus.getFavourited()) + .setBookmarked(actionableStatus.getBookmarked()) + .setFavouritesCount(actionableStatus.getFavouritesCount()) + .createStatusViewData(); + statuses.setPairedItem(position, viewData); + + adapter.setItem(position, viewData, true); + + } + } + + @Override + public void onMore(@NonNull View view, int position) { + super.more(statuses.get(position), view, position); + } + + @Override + public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { + Status status = statuses.get(position); + super.viewMedia(attachmentIndex, status, view); + } + + @Override + public void onViewThread(int position) { + Status status = statuses.get(position); + if (thisThreadsStatusId.equals(status.getId())) { + // If already viewing this thread, don't reopen it. + return; + } + super.viewThread(status); + } + + @Override + public void onViewReplyTo(int position) { + Status status = statuses.get(position); + if (thisThreadsStatusId.equals(status.getInReplyToId())) return; + super.onShowReplyTo(status.getInReplyToId()); + } + + @Override + public void onOpenReblog(int position) { + // there should be no reblogs in the thread but let's implement it to be sure + super.openReblog(statuses.get(position)); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + StatusViewData.Concrete newViewData = + new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsExpanded(expanded) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.setItem(position, newViewData, true); + updateRevealIcon(); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + StatusViewData.Concrete newViewData = + new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsShowingSensitiveContent(isShowing) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.setItem(position, newViewData, true); + } + + @Override + public void onLoadMore(int position) { + + } + + @Override + public void onShowReblogs(int position) { + String statusId = statuses.get(position).getId(); + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + } + + @Override + public void onShowFavs(int position) { + String statusId = statuses.get(position).getId(); + Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId); + ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); + } + + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + if (position < 0 || position >= statuses.size()) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); + return; + } + + StatusViewData.Concrete status = statuses.getPairedItem(position); + if (status == null) { + // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't + // check for null values when adding values to it although this doesn't seem to be an issue. + Log.e(TAG, String.format( + "Expected StatusViewData.Concrete, got null instead at position: %d of %d", + position, + statuses.size() - 1 + )); + return; + } + + StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) + .setCollapsed(isCollapsed) + .createStatusViewData(); + statuses.setPairedItem(position, updatedStatus); + recyclerView.post(() -> adapter.setItem(position, updatedStatus, true)); + } + + @Override + public void onViewTag(String tag) { + super.viewTag(tag); + } + + @Override + public void onViewAccount(String id) { + super.viewAccount(id); + } + + @Override + public void removeItem(int position) { + if (position == statusIndex) { + //the status got removed, close the activity + getActivity().finish(); + } + statuses.remove(position); + updateAdapter(); + } + + public void onVoteInPoll(int position, @NonNull List choices) { + final Status status = statuses.get(position).getActionableStatus(); + + setVoteForPoll(position, status.getPoll().votedCopy(choices)); + + timelineCases.voteInPoll(status, choices) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newPoll) -> setVoteForPoll(position, newPoll), + (t) -> Log.d(TAG, + "Failed to vote in poll: " + status.getId(), t) + ); + + } + + private void setVoteForPoll(int position, Poll newPoll) { + + StatusViewData.Concrete viewData = statuses.getPairedItem(position); + + StatusViewData.Concrete newViewData = new StatusViewData.Builder(viewData) + .setPoll(newPoll) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.setItem(position, newViewData, true); + } + + private void updateAdapter() { + adapter.setStatuses(statuses.getPairedCopy()); + } + + private void removeAllByAccountId(String accountId) { + Status status = null; + if (!statuses.isEmpty()) { + status = statuses.get(statusIndex); + } + // using iterator to safely remove items while iterating + Iterator iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status s = iterator.next(); + if (s.getAccount().getId().equals(accountId) || s.getActionableStatus().getAccount().getId().equals(accountId)) { + iterator.remove(); + } + } + statusIndex = statuses.indexOf(status); + if (statusIndex == -1) { + //the status got removed, close the activity + getActivity().finish(); + return; + } + adapter.setDetailedStatusPosition(statusIndex); + updateAdapter(); + } + + private void sendStatusRequest(final String id) { + Call call = mastodonApi.status(id); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { + int position = setStatus(response.body()); + recyclerView.scrollToPosition(position); + } else { + onThreadRequestFailure(id); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + onThreadRequestFailure(id); + } + }); + callList.add(call); + } + + private void sendThreadRequest(final String id) { + Call call = mastodonApi.statusContext(id); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + StatusContext context = response.body(); + if (response.isSuccessful() && context != null) { + swipeRefreshLayout.setRefreshing(false); + setContext(context.getAncestors(), context.getDescendants()); + } else { + onThreadRequestFailure(id); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + onThreadRequestFailure(id); + } + }); + callList.add(call); + } + + private void onThreadRequestFailure(final String id) { + View view = getView(); + swipeRefreshLayout.setRefreshing(false); + if (view != null) { + Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry, v -> { + sendThreadRequest(id); + sendStatusRequest(id); + }) + .show(); + } else { + Log.e(TAG, "Couldn't display thread fetch error message"); + } + } + + private int setStatus(Status status) { + if (statuses.size() > 0 + && statusIndex < statuses.size() + && statuses.get(statusIndex).equals(status)) { + // Do not add this status on refresh, it's already in there. + statuses.set(statusIndex, status); + return statusIndex; + } + int i = statusIndex; + statuses.add(i, status); + adapter.setDetailedStatusPosition(i); + adapter.addItem(i, statuses.getPairedItem(i)); + updateRevealIcon(); + return i; + } + + private void setContext(List unfilteredAncestors, List unfilteredDescendants) { + Status mainStatus = null; + + // In case of refresh, remove old ancestors and descendants first. We'll remove all blindly, + // as we have no guarantee on their order to be the same as before + int oldSize = statuses.size(); + if (oldSize > 1) { + mainStatus = statuses.get(statusIndex); + statuses.clear(); + adapter.clearItems(); + } + + ArrayList ancestors = new ArrayList<>(); + for (Status status : unfilteredAncestors) + if (!shouldFilterStatus(status)) + ancestors.add(status); + + // Insert newly fetched ancestors + statusIndex = ancestors.size(); + adapter.setDetailedStatusPosition(statusIndex); + statuses.addAll(0, ancestors); + List ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); + if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) { + String error = String.format(Locale.getDefault(), + "Incorrectly got statusViewData sublist." + + " ancestors.size == %d ancestorsViewDatas.size == %d," + + " statuses.size == %d", + ancestors.size(), ancestorsViewDatas.size(), statuses.size()); + throw new AssertionError(error); + } + adapter.addAll(0, ancestorsViewDatas); + + if (mainStatus != null) { + // In case we needed to delete everything (which is way easier than deleting + // everything except one), re-insert the remaining status here. + // Not filtering the main status, since the user explicitly chose to be here + statuses.add(statusIndex, mainStatus); + StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex); + + adapter.addItem(statusIndex, viewData); + } + + ArrayList descendants = new ArrayList<>(); + for (Status status : unfilteredDescendants) + if (!shouldFilterStatus(status)) + descendants.add(status); + + // Insert newly fetched descendants + statuses.addAll(descendants); + List descendantsViewData; + descendantsViewData = statuses.getPairedCopy() + .subList(statuses.size() - descendants.size(), statuses.size()); + if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) { + String error = String.format(Locale.getDefault(), + "Incorrectly got statusViewData sublist." + + " descendants.size == %d descendantsViewData.size == %d," + + " statuses.size == %d", + descendants.size(), descendantsViewData.size(), statuses.size()); + throw new AssertionError(error); + } + adapter.addAll(descendantsViewData); + updateRevealIcon(); + } + + private void setMutedStatusForStatus(int position, Status status, boolean muted) { + StatusViewData.Builder statusViewData = new StatusViewData.Builder(statuses.getPairedItem(position)); + statusViewData.setMuted(muted); + + statuses.setPairedItem(position, statusViewData.createStatusViewData()); + } + + private void handleMuteEvent(MuteEvent event) { + String id = event.getAccountId(); + boolean muting = event.getMute(); + + if(isFilteringMuted()) { + removeAllByAccountId(id); + } else { + for (int i = 0; i < statuses.size(); i++) { + Status status = statuses.get(i); + if (status != null + && status.getAccount().getId().equals(id) + && !status.isThreadMuted()) { + setMutedStatusForStatus(i, status, muting); + } + } + updateAdapter(); + } + } + + + private void handleFavEvent(FavoriteEvent event) { + Pair posAndStatus = findStatusAndPos(event.getStatusId()); + if (posAndStatus == null) return; + + boolean favourite = event.getFavourite(); + posAndStatus.second.setFavourited(favourite); + + if (posAndStatus.second.getReblog() != null) { + posAndStatus.second.getReblog().setFavourited(favourite); + } + + StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); + viewDataBuilder.setFavourited(favourite); + + StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); + + statuses.setPairedItem(posAndStatus.first, newViewData); + adapter.setItem(posAndStatus.first, newViewData, true); + } + + private void handleReblogEvent(ReblogEvent event) { + Pair posAndStatus = findStatusAndPos(event.getStatusId()); + if (posAndStatus == null) return; + + boolean reblog = event.getReblog(); + posAndStatus.second.setReblogged(reblog); + + if (posAndStatus.second.getReblog() != null) { + posAndStatus.second.getReblog().setReblogged(reblog); + } + + StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); + viewDataBuilder.setReblogged(reblog); + + StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); + + statuses.setPairedItem(posAndStatus.first, newViewData); + adapter.setItem(posAndStatus.first, newViewData, true); + } + + private void handleBookmarkEvent(BookmarkEvent event) { + Pair posAndStatus = findStatusAndPos(event.getStatusId()); + if (posAndStatus == null) return; + + boolean bookmark = event.getBookmark(); + posAndStatus.second.setBookmarked(bookmark); + + if (posAndStatus.second.getReblog() != null) { + posAndStatus.second.getReblog().setBookmarked(bookmark); + } + + StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); + + StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); + viewDataBuilder.setBookmarked(bookmark); + + StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); + + statuses.setPairedItem(posAndStatus.first, newViewData); + adapter.setItem(posAndStatus.first, newViewData, true); + } + + private void handleStatusComposedEvent(StatusComposedEvent event) { + Status eventStatus = event.getStatus(); + if (eventStatus.getInReplyToId() == null) return; + + if (eventStatus.getInReplyToId().equals(thisThreadsStatusId)) { + insertStatus(eventStatus, statuses.size()); + } else { + // If new status is a reply to some status in the thread, insert new status after it + // We only check statuses below main status, ones on top don't belong to this thread + for (int i = statusIndex; i < statuses.size(); i++) { + Status status = statuses.get(i); + if (eventStatus.getInReplyToId().equals(status.getId())) { + insertStatus(eventStatus, i + 1); + break; + } + } + } + } + + private int thisThreadsStatusPosition = -1; + private void insertStatus(Status status, int at) { + statuses.add(at, status); + adapter.addItem(at, statuses.getPairedItem(at)); + if(status.getId().equals(thisThreadsStatusId)) { + thisThreadsStatusPosition = at; + } + } + + private void handleStatusDeletedEvent(StatusDeletedEvent event) { + Pair posAndStatus = findStatusAndPos(event.getStatusId()); + if (posAndStatus == null) return; + + @SuppressWarnings("ConstantConditions") + int pos = posAndStatus.first; + statuses.remove(pos); + adapter.removeItem(pos); + } + + @Nullable + private Pair findStatusAndPos(@NonNull String statusId) { + for (int i = 0; i < statuses.size(); i++) { + if (statusId.equals(statuses.get(i).getId())) { + return new Pair<>(i, statuses.get(i)); + } + } + return null; + } + + private void updateRevealIcon() { + ViewThreadActivity activity = ((ViewThreadActivity) getActivity()); + if (activity == null) return; + + boolean hasAnyWarnings = false; + // Statuses are updated from the main thread so nothing should change while iterating + for (int i = 0; i < statuses.size(); i++) { + if (!TextUtils.isEmpty(statuses.get(i).getSpoilerText())) { + hasAnyWarnings = true; + break; + } + } + if (!hasAnyWarnings) { + activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN); + return; + } + activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE : + ViewThreadActivity.REVEAL_BUTTON_REVEAL); + } + + @Override + protected boolean filterIsRelevant(@NonNull Filter filter) { + return filter.getContext().contains(Filter.THREAD); + } + + @Override + protected void refreshAfterApplyingFilters() { + onRefresh(); + } + + private void setEmojiReactionForStatus(int position, Status status) { + StatusViewData.Concrete newViewData = ViewDataUtils.statusToViewData(status, false, false); + + statuses.setPairedItem(position, newViewData); + adapter.setItem(position, newViewData, true); + } + + public void handleEmojiReactEvent(EmojiReactEvent event) { + Pair posAndStatus = findStatusAndPos(event.getNewStatus().getActionableId()); + if (posAndStatus == null) return; + setEmojiReactionForStatus(posAndStatus.first, event.getNewStatus()); + } + + @Override + public void onEmojiReact(final boolean react, final String emoji, final String statusId) { + Pair statusAndPos = findStatusAndPos(statusId); + + if(statusAndPos == null) + return; + int position = statusAndPos.first; + + timelineCases.react(emoji, statusId, react) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setEmojiReactionForStatus(position, newStatus), + (t) -> Log.d(TAG, + "Failed to react with " + emoji + " on status: " + statusId, t) + ); + + } + + @Override + public void onEmojiReactMenu(@NonNull View view, final EmojiReaction emoji, final String statusId) { + super.emojiReactMenu(statusId, emoji, view, this); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt new file mode 100644 index 0000000..6f3c8ab --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -0,0 +1,207 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.MediaController +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView +import kotlinx.android.synthetic.main.activity_view_media.* +import kotlinx.android.synthetic.main.fragment_view_video.* + +class ViewVideoFragment : ViewMediaFragment() { + private lateinit var toolbar: View + private val handler = Handler(Looper.getMainLooper()) + private val hideToolbar = Runnable { + // Hoist toolbar hiding to activity so it can track state across different fragments + // This is explicitly stored as runnable so that we pass it to the handler later for cancellation + mediaActivity.onPhotoTap() + mediaController.hide() + } + private lateinit var mediaActivity: ViewMediaActivity + private val TOOLBAR_HIDE_DELAY_MS = 3000L + private lateinit var mediaController : MediaController + private var isAudio = false + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + // Start/pause/resume video playback as fragment is shown/hidden + super.setUserVisibleHint(isVisibleToUser) + if (videoView == null) { + return + } + + if (isVisibleToUser) { + if (mediaActivity.isToolbarVisible) { + handler.postDelayed(hideToolbar, TOOLBAR_HIDE_DELAY_MS) + } + videoView.start() + } else { + handler.removeCallbacks(hideToolbar) + videoView.pause() + mediaController.hide() + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun setupMediaView( + url: String, + previewUrl: String?, + description: String?, + showingDescription: Boolean + ) { + mediaDescription.text = description + mediaDescription.visible(showingDescription) + + videoView.transitionName = url + videoView.setVideoPath(url) + mediaController = object : MediaController(mediaActivity) { + override fun show(timeout: Int) { + // We're doing manual auto-close management. + // Also, take focus back from the pause button so we can use the back button. + super.show(0) + mediaController.requestFocus() + } + + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + if (event?.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) { + hide() + activity?.supportFinishAfterTransition() + } + return true + } + return super.dispatchKeyEvent(event) + } + } + + mediaController.setMediaPlayer(videoView) + videoView.setMediaController(mediaController) + videoView.requestFocus() + videoView.setPlayPauseListener(object: ExposedPlayPauseVideoView.PlayPauseListener { + override fun onPause() { + handler.removeCallbacks(hideToolbar) + } + override fun onPlay() { + // Audio doesn't cause the controller to show automatically, + // and we only want to hide the toolbar if it's a video. + if (isAudio) { + mediaController.show() + } else { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + } + } + }) + videoView.setOnPreparedListener { mp -> + val containerWidth = videoContainer.measuredWidth.toFloat() + val containerHeight = videoContainer.measuredHeight.toFloat() + val videoWidth = mp.videoWidth.toFloat() + val videoHeight = mp.videoHeight.toFloat() + + if(containerWidth/containerHeight > videoWidth/videoHeight) { + videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT + } else { + videoView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + } + + // Wait until the media is loaded before accepting taps as we don't want toolbar to + // be hidden until then. + videoView.setOnTouchListener { _, _ -> + mediaActivity.onPhotoTap() + false + } + + progressBar.hide() + mp.isLooping = true + if (arguments!!.getBoolean(ARG_START_POSTPONED_TRANSITION)) { + videoView.start() + } + } + + if (arguments!!.getBoolean(ARG_START_POSTPONED_TRANSITION)) { + mediaActivity.onBringUp() + } + } + + private fun hideToolbarAfterDelay(delayMilliseconds: Long) { + handler.postDelayed(hideToolbar, delayMilliseconds) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + toolbar = activity!!.toolbar + mediaActivity = activity as ViewMediaActivity + return inflater.inflate(R.layout.fragment_view_video, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val attachment = arguments?.getParcelable(ARG_ATTACHMENT) + val url: String + + if (attachment == null) { + throw IllegalArgumentException("attachment has to be set") + } + url = attachment.url + isAudio = attachment.type == Attachment.Type.AUDIO + finalizeViewSetup(url, attachment.previewUrl, attachment.description) + } + + override fun onToolbarVisibilityChange(visible: Boolean) { + if (videoView == null || mediaDescription == null || !userVisibleHint) { + return + } + + isDescriptionVisible = showingDescription && visible + val alpha = if (isDescriptionVisible) 1.0f else 0.0f + if (isDescriptionVisible) { + // If to be visible, need to make visible immediately and animate alpha + mediaDescription.alpha = 0.0f + mediaDescription.visible(isDescriptionVisible) + } + + mediaDescription.animate().alpha(alpha) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + mediaDescription?.visible(isDescriptionVisible) + animation.removeListener(this) + } + }) + .start() + + if (visible && videoView.isPlaying && !isAudio) { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + } else { + handler.removeCallbacks(hideToolbar) + } + } + + override fun onTransitionEnd() { + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java new file mode 100644 index 0000000..c353d0f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java @@ -0,0 +1,23 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.interfaces; + +public interface AccountActionListener { + void onViewAccount(String id); + void onMute(final boolean mute, final String id, final int position, final boolean notifications); + void onBlock(final boolean block, final String id, final int position); + void onRespondToFollowRequest(final boolean accept, final String id, final int position); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt new file mode 100644 index 0000000..04b1ebd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt @@ -0,0 +1,22 @@ +/* Copyright 2019 Levi Bard + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.interfaces + +import com.keylesspalace.tusky.db.AccountEntity + +interface AccountSelectionListener { + fun onAccountSelected(account: AccountEntity) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java new file mode 100644 index 0000000..023ddde --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java @@ -0,0 +1,28 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.interfaces; + +import androidx.annotation.Nullable; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +public interface ActionButtonActivity { + + /* return the ActionButton of the Activity to hide or show it on scroll */ + @Nullable + FloatingActionButton getActionButton(); + + default void onActionButtonHidden() {} +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/ChatActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/ChatActionListener.kt new file mode 100644 index 0000000..0a25b58 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/ChatActionListener.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.interfaces + +import android.view.View +import com.keylesspalace.tusky.entity.Chat + +interface ChatActionListener: LinkListener { + fun onLoadMore(position: Int) {} + fun onMore(chatId: String, v: View) {} + fun openChat(position: Int) {} + fun onViewMedia(position: Int, view: View?) {} +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java new file mode 100644 index 0000000..90599b2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java @@ -0,0 +1,22 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.interfaces; + +public interface LinkListener { + void onViewTag(String tag); + void onViewAccount(String id); + void onViewUrl(String url); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.java new file mode 100644 index 0000000..ca83e08 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.java @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.interfaces; + +public interface PermissionRequester { + void onRequestPermissionsResult(String[] permissions, int[] grantResults); +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt new file mode 100644 index 0000000..5032774 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.interfaces + +/** + * Created by pandasoft (joelpyska1@gmail.com) on 04/04/2019. + */ +interface RefreshableFragment { + /** + * Call this method to refresh fragment content + */ + fun refreshContent() +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt new file mode 100644 index 0000000..c50178c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.interfaces + +/** + * Created by pandasoft (joelpyska1@gmail.com) on 04/04/2019. + */ +interface ReselectableFragment { + /** + * Call this method when tab reselected + */ + fun onReselect() +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java new file mode 100644 index 0000000..d956cec --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -0,0 +1,72 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.interfaces; + +import android.view.View; + +import java.util.List; +import com.keylesspalace.tusky.entity.EmojiReaction; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public interface StatusActionListener extends LinkListener { + void onReply(int position); + void onReblog(final boolean reblog, final int position); + void onFavourite(final boolean favourite, final int position); + void onBookmark(final boolean bookmark, final int position); + void onMore(@NonNull View view, final int position); + void onViewMedia(int position, int attachmentIndex, @Nullable View view); + void onViewThread(int position); + void onViewReplyTo(int position); + + /** + * Open reblog author for the status. + * @param position At which position in the list status is located + */ + void onOpenReblog(int position); + void onExpandedChange(boolean expanded, int position); + void onContentHiddenChange(boolean isShowing, int position); + void onLoadMore(int position); + + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onContentCollapsedChange(boolean isCollapsed, int position); + + /** + * called when the reblog count has been clicked + * @param position The position of the status in the list. + */ + default void onShowReblogs(int position) {} + + /** + * called when the favourite count has been clicked + * @param position The position of the status in the list. + */ + default void onShowFavs(int position) {} + + void onVoteInPoll(int position, @NonNull List choices); + + default void onMute(int position, boolean isMuted) {} + default void onEmojiReact(@NonNull final boolean react, @NonNull final String emoji, @NonNull final String statusId) {}; + default void onEmojiReactMenu(@NonNull View view, @NonNull final EmojiReaction emoji, @NonNull final String statusId) {}; + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt new file mode 100644 index 0000000..6eabea5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt @@ -0,0 +1,37 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.json + +import android.text.Spanned +import android.text.SpannedString +import androidx.core.text.HtmlCompat +import androidx.core.text.parseAsHtml +import com.google.gson.* +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import java.lang.reflect.Type + +class SpannedTypeAdapter : JsonDeserializer, JsonSerializer { + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned { + /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which + * all status contents do, so it should be trimmed. */ + return json.asString?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString("") + } + + override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonPrimitive(HtmlCompat.toHtml(src!!, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.java b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.java new file mode 100644 index 0000000..2dcedd8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.java @@ -0,0 +1,76 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.network; + +import androidx.annotation.NonNull; + +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; + +import java.io.IOException; + +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +/** + * Created by charlag on 31/10/17. + */ + +public final class InstanceSwitchAuthInterceptor implements Interceptor { + private AccountManager accountManager; + + public InstanceSwitchAuthInterceptor(AccountManager accountManager) { + this.accountManager = accountManager; + } + + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + + Request originalRequest = chain.request(); + + // only switch domains if the request comes from retrofit + if (originalRequest.url().host().equals(MastodonApi.PLACEHOLDER_DOMAIN)) { + AccountEntity currentAccount = accountManager.getActiveAccount(); + + Request.Builder builder = originalRequest.newBuilder(); + + String instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER); + if (instanceHeader != null) { + // use domain explicitly specified in custom header + builder.url(swapHost(originalRequest.url(), instanceHeader)); + builder.removeHeader(MastodonApi.DOMAIN_HEADER); + } else if (currentAccount != null) { + //use domain of current account + builder.url(swapHost(originalRequest.url(), currentAccount.getDomain())) + .header("Authorization", + String.format("Bearer %s", currentAccount.getAccessToken())); + } + Request newRequest = builder.build(); + + return chain.proceed(newRequest); + + } else { + return chain.proceed(originalRequest); + } + } + + @NonNull + private static HttpUrl swapHost(@NonNull HttpUrl url, @NonNull String host) { + return url.newBuilder().host(host).build(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt new file mode 100644 index 0000000..dda8201 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -0,0 +1,684 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.network + +import com.keylesspalace.tusky.entity.* +import io.reactivex.Completable +import io.reactivex.Single +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Response +import retrofit2.http.* +import retrofit2.http.Field + +/** + * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ + */ + +@JvmSuppressWildcards +interface MastodonApi { + + companion object { + const val ENDPOINT_AUTHORIZE = "/oauth/authorize" + const val DOMAIN_HEADER = "domain" + const val PLACEHOLDER_DOMAIN = "dummy.placeholder" + } + + @GET("/api/v1/lists") + fun getLists(): Single> + + @GET("/api/v1/custom_emojis") + fun getCustomEmojis(): Single> + + @GET("api/v1/instance") + fun getInstance(): Single + + @GET("api/v1/filters") + fun getFilters(): Call> + + @GET("api/v1/timelines/home?with_muted=true") + fun homeTimeline( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Call> + + @GET("api/v1/timelines/home?with_muted=true") + fun homeTimelineSingle( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Single> + + @GET("api/v1/timelines/public?with_muted=true") + fun publicTimeline( + @Query("local") local: Boolean?, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Call> + + @GET("api/v1/timelines/tag/{hashtag}?with_muted=true") + fun hashtagTimeline( + @Path("hashtag") hashtag: String, + @Query("any[]") any: List?, + @Query("local") local: Boolean?, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Call> + + @GET("api/v1/timelines/list/{listId}?with_muted=true") + fun listTimeline( + @Path("listId") listId: String, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Call> + + @GET("api/v1/notifications") + fun notifications( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_types[]") excludes: Set?, + @Query("with_muted") withMuted: Boolean? + ): Call> + + @GET("api/v1/markers") + fun markersWithAuth( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Query("timeline[]") timelines: List + ): Single> + + @GET("api/v1/notifications?with_muted=true") + fun notificationsWithAuth( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Query("since_id") sinceId: String?, + @Query("include_types[]") includeTypes: List? + ): Single> + + @POST("api/v1/notifications/clear") + fun clearNotifications(): Call + + @GET("api/v1/notifications/{id}") + fun notification( + @Path("id") notificationId: String + ): Call + + @Multipart + @POST("api/v1/media") + fun uploadMedia( + @Part file: MultipartBody.Part, + @Part description: MultipartBody.Part? = null + ): Single + + @FormUrlEncoded + @PUT("api/v1/media/{mediaId}") + fun updateMedia( + @Path("mediaId") mediaId: String, + @Field("description") description: String + ): Single + + @POST("api/v1/statuses") + fun createStatus( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Header("Idempotency-Key") idempotencyKey: String, + @Body status: NewStatus + ): Call + + @GET("api/v1/statuses/{id}") + fun status( + @Path("id") statusId: String + ): Call + + @GET("api/v1/statuses/{id}") + fun statusSingle( + @Path("id") statusId: String + ): Single + + @GET("api/v1/statuses/{id}/context") + fun statusContext( + @Path("id") statusId: String + ): Call + + @GET("api/v1/statuses/{id}/reblogged_by") + fun statusRebloggedBy( + @Path("id") statusId: String, + @Query("max_id") maxId: String? + ): Single>> + + @GET("api/v1/statuses/{id}/favourited_by") + fun statusFavouritedBy( + @Path("id") statusId: String, + @Query("max_id") maxId: String? + ): Single>> + + @DELETE("api/v1/statuses/{id}") + fun deleteStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/reblog") + fun reblogStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unreblog") + fun unreblogStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/favourite") + fun favouriteStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unfavourite") + fun unfavouriteStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/bookmark") + fun bookmarkStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unbookmark") + fun unbookmarkStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/pin") + fun pinStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unpin") + fun unpinStatus( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/mute") + fun muteConversation( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unmute") + fun unmuteConversation( + @Path("id") statusId: String + ): Single + + @GET("api/v1/scheduled_statuses") + fun scheduledStatuses( + @Query("limit") limit: Int? = null, + @Query("max_id") maxId: String? = null + ): Single> + + @DELETE("api/v1/scheduled_statuses/{id}") + fun deleteScheduledStatus( + @Path("id") scheduledStatusId: String + ): Single + + @GET("api/v1/accounts/verify_credentials") + fun accountVerifyCredentials(): Single + + @FormUrlEncoded + @PATCH("api/v1/accounts/update_credentials") + fun accountUpdateSource( + @Field("source[privacy]") privacy: String?, + @Field("source[sensitive]") sensitive: Boolean? + ): Call + + @Multipart + @PATCH("api/v1/accounts/update_credentials") + fun accountUpdateCredentials( + @Part(value = "display_name") displayName: RequestBody?, + @Part(value = "note") note: RequestBody?, + @Part(value = "locked") locked: RequestBody?, + @Part avatar: MultipartBody.Part?, + @Part header: MultipartBody.Part?, + @Part(value = "fields_attributes[0][name]") fieldName0: RequestBody?, + @Part(value = "fields_attributes[0][value]") fieldValue0: RequestBody?, + @Part(value = "fields_attributes[1][name]") fieldName1: RequestBody?, + @Part(value = "fields_attributes[1][value]") fieldValue1: RequestBody?, + @Part(value = "fields_attributes[2][name]") fieldName2: RequestBody?, + @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, + @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, + @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? + ): Call + + @GET("api/v1/accounts/search") + fun searchAccounts( + @Query("q") query: String, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("following") following: Boolean? = null + ): Single> + + @GET("api/v1/accounts/{id}") + fun account( + @Path("id") accountId: String + ): Single + + /** + * Method to fetch statuses for the specified account. + * @param accountId ID for account for which statuses will be requested + * @param maxId Only statuses with ID less than maxID will be returned + * @param sinceId Only statuses with ID bigger than sinceID will be returned + * @param limit Limit returned statuses (current API limits: default - 20, max - 40) + * @param excludeReplies only return statuses that are no replies + * @param onlyMedia only return statuses that have media attached + */ + @GET("api/v1/accounts/{id}/statuses?with_muted=true") + fun accountStatuses( + @Path("id") accountId: String, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_replies") excludeReplies: Boolean?, + @Query("only_media") onlyMedia: Boolean?, + @Query("pinned") pinned: Boolean? + ): Call> + + @GET("api/v1/accounts/{id}/followers") + fun accountFollowers( + @Path("id") accountId: String, + @Query("max_id") maxId: String? + ): Single>> + + @GET("api/v1/accounts/{id}/following") + fun accountFollowing( + @Path("id") accountId: String, + @Query("max_id") maxId: String? + ): Single>> + + @FormUrlEncoded + @POST("api/v1/accounts/{id}/follow") + fun followAccount( + @Path("id") accountId: String, + @Field("reblogs") showReblogs: Boolean? = null, + @Field("notify") notify: Boolean? = null + ): Single + + @POST("api/v1/accounts/{id}/unfollow") + fun unfollowAccount( + @Path("id") accountId: String + ): Single + + @POST("api/v1/accounts/{id}/block") + fun blockAccount( + @Path("id") accountId: String + ): Single + + @POST("api/v1/accounts/{id}/unblock") + fun unblockAccount( + @Path("id") accountId: String + ): Single + + @FormUrlEncoded + @POST("api/v1/accounts/{id}/mute") + fun muteAccount( + @Path("id") accountId: String, + @Field("notifications") notifications: Boolean? = null, + @Field("duration") duration: Int? = null + ): Single + + @POST("api/v1/accounts/{id}/unmute") + fun unmuteAccount( + @Path("id") accountId: String + ): Single + + @GET("api/v1/accounts/relationships") + fun relationships( + @Query("id[]") accountIds: List + ): Single> + + @GET("api/v1/accounts/{id}/identity_proofs") + fun identityProofs( + @Path("id") accountId: String + ): Single> + + @POST("api/v1/pleroma/accounts/{id}/subscribe") + fun subscribeAccount( + @Path("id") accountId: String + ): Single + + @POST("api/v1/pleroma/accounts/{id}/unsubscribe") + fun unsubscribeAccount( + @Path("id") accountId: String + ): Single + + @GET("api/v1/blocks") + fun blocks( + @Query("max_id") maxId: String? + ): Single>> + + @GET("api/v1/mutes") + fun mutes( + @Query("max_id") maxId: String? + ): Single>> + + @GET("api/v1/domain_blocks") + fun domainBlocks( + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null + ): Single>> + + @FormUrlEncoded + @POST("api/v1/domain_blocks") + fun blockDomain( + @Field("domain") domain: String + ): Call + + @FormUrlEncoded + // @DELETE doesn't support fields + @HTTP(method = "DELETE", path = "api/v1/domain_blocks", hasBody = true) + fun unblockDomain(@Field("domain") domain: String): Call + + @GET("api/v1/favourites?with_muted=true") + fun favourites( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Call> + + @GET("api/v1/bookmarks?with_muted=true") + fun bookmarks( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? + ): Call> + + @GET("api/v1/follow_requests") + fun followRequests( + @Query("max_id") maxId: String? + ): Single>> + + @POST("api/v1/follow_requests/{id}/authorize") + fun authorizeFollowRequest( + @Path("id") accountId: String + ): Call + + @POST("api/v1/follow_requests/{id}/reject") + fun rejectFollowRequest( + @Path("id") accountId: String + ): Call + + @POST("api/v1/follow_requests/{id}/authorize") + fun authorizeFollowRequestObservable( + @Path("id") accountId: String + ): Single + + @POST("api/v1/follow_requests/{id}/reject") + fun rejectFollowRequestObservable( + @Path("id") accountId: String + ): Single + + @FormUrlEncoded + @POST("api/v1/apps") + fun authenticateApp( + @Header(DOMAIN_HEADER) domain: String, + @Field("client_name") clientName: String, + @Field("redirect_uris") redirectUris: String, + @Field("scopes") scopes: String, + @Field("website") website: String + ): Call + + @FormUrlEncoded + @POST("oauth/token") + fun fetchOAuthToken( + @Header(DOMAIN_HEADER) domain: String, + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("redirect_uri") redirectUri: String, + @Field("code") code: String, + @Field("grant_type") grantType: String + ): Call + + @FormUrlEncoded + @POST("api/v1/lists") + fun createList( + @Field("title") title: String + ): Single + + @FormUrlEncoded + @PUT("api/v1/lists/{listId}") + fun updateList( + @Path("listId") listId: String, + @Field("title") title: String + ): Single + + @DELETE("api/v1/lists/{listId}") + fun deleteList( + @Path("listId") listId: String + ): Completable + + @GET("api/v1/lists/{listId}/accounts") + fun getAccountsInList( + @Path("listId") listId: String, + @Query("limit") limit: Int + ): Single> + + @FormUrlEncoded + // @DELETE doesn't support fields + @HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true) + fun deleteAccountFromList( + @Path("listId") listId: String, + @Field("account_ids[]") accountIds: List + ): Completable + + @FormUrlEncoded + @POST("api/v1/lists/{listId}/accounts") + fun addCountToList( + @Path("listId") listId: String, + @Field("account_ids[]") accountIds: List + ): Completable + + @GET("/api/v1/conversations") + fun getConversations( + @Query("max_id") maxId: String? = null, + @Query("limit") limit: Int + ): Call> + + data class PostFilter( + val phrase: String, + val context: List, + val irreversible: Boolean?, + val whole_word: Boolean?, + val expires_in: String? + ); + + @POST("api/v1/filters") + fun createFilter(@Body body: PostFilter): Call + + @PUT("api/v1/filters/{id}") + fun updateFilter( + @Path("id") id: String, + @Body body: PostFilter + ): Call + + @DELETE("api/v1/filters/{id}") + fun deleteFilter( + @Path("id") id: String + ): Call + + @FormUrlEncoded + @POST("api/v1/polls/{id}/votes") + fun voteInPoll( + @Path("id") id: String, + @Field("choices[]") choices: List + ): Single + + @GET("api/v1/announcements") + fun listAnnouncements( + @Query("with_dismissed") withDismissed: Boolean = true + ): Single> + + @POST("api/v1/announcements/{id}/dismiss") + fun dismissAnnouncement( + @Path("id") announcementId: String + ): Single + + @PUT("api/v1/announcements/{id}/reactions/{name}") + fun addAnnouncementReaction( + @Path("id") announcementId: String, + @Path("name") name: String + ): Single + + @DELETE("api/v1/announcements/{id}/reactions/{name}") + fun removeAnnouncementReaction( + @Path("id") announcementId: String, + @Path("name") name: String + ): Single + + @FormUrlEncoded + @POST("api/v1/reports") + fun reportObservable( + @Field("account_id") accountId: String, + @Field("status_ids[]") statusIds: List, + @Field("comment") comment: String, + @Field("forward") isNotifyRemote: Boolean? + ): Single + + @GET("api/v1/accounts/{id}/statuses?with_muted=true") + fun accountStatusesObservable( + @Path("id") accountId: String, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_reblogs") excludeReblogs: Boolean? + ): Single> + + @GET("api/v1/statuses/{id}") + fun statusObservable( + @Path("id") statusId: String + ): Single + + @GET("api/v2/search") + fun searchObservable( + @Query("q") query: String?, + @Query("type") type: String? = null, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("following") following: Boolean? = null + ): Single + + @GET(".well-known/nodeinfo") + fun getNodeinfoLinks() : Single + + @GET + fun getNodeinfo(@Url url: String) : Single + + @PUT("api/v1/pleroma/statuses/{id}/reactions/{emoji}") + fun reactWithEmoji( + @Path("id") statusId: String, + @Path("emoji") emoji: String + ): Single + + @DELETE("api/v1/pleroma/statuses/{id}/reactions/{emoji}") + fun unreactWithEmoji( + @Path("id") statusId: String, + @Path("emoji") emoji: String + ): Single + + @GET("api/v1/pleroma/statuses/{id}/reactions/{emoji}") + fun statusReactedBy( + @Path("id") statusId: String, + @Path("emoji") emoji: String + ): Single>> + + // NOT AN API CALLS NOT AN API CALLS NOT AN API CALLS NOT AN API CALLS + // just for testing and because puniko asked me + @GET("static/stickers.json") + fun getStickers() : Single> + + @GET + fun getStickerPack( + @Url path: String + ): Single> + // NOT AN API CALLS NOT AN API CALLS NOT AN API CALLS NOT AN API CALLS + + @POST("api/v1/pleroma/chats/{id}/messages/{message_id}/read") + fun markChatMessageAsRead( + @Path("id") chatId: String, + @Path("message_id") messageId: String + ): Single + + @DELETE("api/v1/pleroma/chats/{id}/messages/{message_id}") + fun deleteChatMessage( + @Path("id") chatId: String, + @Path("message_id") messageId: String + ): Single + + @GET("api/v1/pleroma/chats") + fun getChats( + @Query("max_id") maxId: String?, + @Query("min_id") minId: String?, + @Query("since_id") sinceId: String?, + @Query("offset") offset: Int?, + @Query("limit") limit: Int? + ): Single> + + @GET("api/v1/pleroma/chats/{id}/messages") + fun getChatMessages( + @Path("id") chatId: String, + @Query("max_id") maxId: String?, + @Query("min_id") minId: String?, + @Query("since_id") sinceId: String?, + @Query("offset") offset: Int?, + @Query("limit") limit: Int? + ): Single> + + @POST("api/v1/pleroma/chats/{id}/messages") + fun createChatMessage( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Path("id") chatId: String, + @Body chatMessage: NewChatMessage + ): Call + + @FormUrlEncoded + @POST("api/v1/pleroma/chats/{id}/read") + fun markChatAsRead( + @Path("id") chatId: String, + @Field("last_read_id") lastReadId: String? = null + ): Single + + @POST("api/v1/pleroma/chats/by-account-id/{id}") + fun createChat( + @Path("id") accountId: String + ): Single + + @GET("api/v1/pleroma/chats/{id}") + fun getChat( + @Path("id") chatId: String + ): Single + + @FormUrlEncoded + @POST("api/v1/accounts/{id}/note") + fun updateAccountNote( + @Path("id") accountId: String, + @Field("comment") note: String + ): Single +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java new file mode 100644 index 0000000..d559d35 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java @@ -0,0 +1,74 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.network; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; + +public final class ProgressRequestBody extends RequestBody { + private final InputStream content; + private final long contentLength; + private final UploadCallback uploadListener; + private final MediaType mediaType; + + private static final int DEFAULT_BUFFER_SIZE = 2048; + + public interface UploadCallback { + void onProgressUpdate(int percentage); + } + + public ProgressRequestBody(final InputStream content, long contentLength, final MediaType mediaType, final UploadCallback listener) { + this.content = content; + this.contentLength = contentLength; + this.mediaType = mediaType; + this.uploadListener = listener; + } + + @Override + public MediaType contentType() { + return mediaType; + } + + @Override + public long contentLength() { + return contentLength; + } + + @Override + public void writeTo(@NonNull BufferedSink sink) throws IOException { + + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + long uploaded = 0; + + try { + int read; + while ((read = content.read(buffer)) != -1) { + uploadListener.onProgressUpdate((int)(100 * uploaded / contentLength)); + + uploaded += read; + sink.write(buffer, 0, read); + } + } finally { + content.close(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt new file mode 100644 index 0000000..f138749 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -0,0 +1,168 @@ +/* Copyright 2018 charlag + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.network + +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import io.reactivex.Single +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import java.lang.IllegalStateException +import android.util.Log + +/** + * Created by charlag on 3/24/18. + */ + +interface TimelineCases { + fun reblog(status: Status, reblog: Boolean): Single + fun favourite(status: Status, favourite: Boolean): Single + fun bookmark(status: Status, bookmark: Boolean): Single + fun muteConversation(status: Status, mute: Boolean) + fun mute(id: String, notifications: Boolean, duration: Int) + fun block(id: String) + fun delete(id: String): Single + fun pin(status: Status, pin: Boolean) + fun voteInPoll(status: Status, choices: List): Single + fun react(emoji: String, id: String, react: Boolean) : Single +} + +class TimelineCasesImpl( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) : TimelineCases { + + /** + * Unused yet but can be use for cancellation later. It's always a good idea to save + * Disposables. + */ + private val cancelDisposable = CompositeDisposable() + + override fun reblog(status: Status, reblog: Boolean): Single { + val id = status.actionableId + + val call = if (reblog) { + mastodonApi.reblogStatus(id) + } else { + mastodonApi.unreblogStatus(id) + } + return call.doAfterSuccess { + eventHub.dispatch(ReblogEvent(status.id, reblog)) + } + } + + override fun favourite(status: Status, favourite: Boolean): Single { + val id = status.actionableId + + val call = if (favourite) { + mastodonApi.favouriteStatus(id) + } else { + mastodonApi.unfavouriteStatus(id) + } + return call.doAfterSuccess { + eventHub.dispatch(FavoriteEvent(status.id, favourite)) + } + } + + override fun bookmark(status: Status, bookmark: Boolean): Single { + val id = status.actionableId + + val call = if (bookmark) { + mastodonApi.bookmarkStatus(id) + } else { + mastodonApi.unbookmarkStatus(id) + } + return call.doAfterSuccess { + eventHub.dispatch(BookmarkEvent(status.id, bookmark)) + } + } + + override fun muteConversation(status: Status, mute: Boolean) { + val id = status.actionableId + if (mute) { + mastodonApi.muteConversation(id) + } else { + mastodonApi.unmuteConversation(id) + } + .subscribe({ + eventHub.dispatch(MuteConversationEvent(id, mute)) + }, { t -> + Log.w("Failed to mute status", t) + }) + .addTo(cancelDisposable) + } + + override fun mute(id: String, notifications: Boolean, duration: Int) { + mastodonApi.muteAccount(id, notifications, duration) + .subscribe({ + eventHub.dispatch(MuteEvent(id, true)) + }, { t -> + Log.w("Failed to mute account", t) + }) + .addTo(cancelDisposable) + } + + override fun block(id: String) { + mastodonApi.blockAccount(id) + .subscribe({ + eventHub.dispatch(BlockEvent(id)) + }, { t -> + Log.w("Failed to block account", t) + }) + .addTo(cancelDisposable) + } + + override fun delete(id: String): Single { + return mastodonApi.deleteStatus(id) + .doAfterSuccess { + eventHub.dispatch(StatusDeletedEvent(id)) + } + } + + override fun pin(status: Status, pin: Boolean) { + // Replace with extension method if we use RxKotlin + (if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id)) + .subscribe({ updatedStatus -> + status.pinned = updatedStatus.pinned + }, {}) + .addTo(this.cancelDisposable) + } + + override fun voteInPoll(status: Status, choices: List): Single { + val pollId = status.actionableStatus.poll?.id + + if(pollId == null || choices.isEmpty()) { + return Single.error(IllegalStateException()) + } + + return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess { + eventHub.dispatch(PollVoteEvent(status.id, it)) + } + } + + override fun react(emoji: String, id: String, react: Boolean): Single { + val call = if (react) { + mastodonApi.reactWithEmoji(id, emoji) + } else { + mastodonApi.unreactWithEmoji(id, emoji) + } + return call.doAfterSuccess { status -> + eventHub.dispatch(EmojiReactEvent(status)) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt new file mode 100644 index 0000000..f8c026e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt @@ -0,0 +1,55 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.pager + +import androidx.fragment.app.* + +import com.keylesspalace.tusky.fragment.AccountMediaFragment +import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.interfaces.RefreshableFragment + +import com.keylesspalace.tusky.util.CustomFragmentStateAdapter + +class AccountPagerAdapter( + activity: FragmentActivity, + private val accountId: String +) : CustomFragmentStateAdapter(activity) { + + override fun getItemCount() = TAB_COUNT + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false) + 1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false) + 2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false) + 3 -> AccountMediaFragment.newInstance(accountId, false) + else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") + } + } + + fun refreshContent() { + for (i in 0 until TAB_COUNT) { + val fragment = getFragment(i) + if (fragment != null && fragment is RefreshableFragment) { + (fragment as RefreshableFragment).refreshContent() + } + } + } + + companion object { + private const val TAB_COUNT = 4 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/AvatarImagePagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/AvatarImagePagerAdapter.kt new file mode 100644 index 0000000..a3dfcf2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AvatarImagePagerAdapter.kt @@ -0,0 +1,25 @@ +package com.keylesspalace.tusky.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.keylesspalace.tusky.ViewMediaAdapter +import com.keylesspalace.tusky.fragment.ViewMediaFragment + +class AvatarImagePagerAdapter( + activity: FragmentActivity, + private val avatarUrl: String +) : ViewMediaAdapter(activity) { + + override fun createFragment(position: Int): Fragment { + return if (position == 0) { + ViewMediaFragment.newAvatarInstance(avatarUrl) + } else { + throw IllegalStateException() + } + } + + override fun getItemCount() = 1 + + override fun onTransitionEnd(position: Int) { + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt new file mode 100644 index 0000000..4f813d8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt @@ -0,0 +1,42 @@ +package com.keylesspalace.tusky.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.keylesspalace.tusky.ViewMediaAdapter +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.fragment.ViewMediaFragment +import java.lang.ref.WeakReference + +class ImagePagerAdapter( + activity: FragmentActivity, + private val attachments: List, + private val initialPosition: Int +) : ViewMediaAdapter(activity) { + + private var didTransition = false + private val fragments = MutableList?>(attachments.size) { null } + + override fun getItemCount() = attachments.size + + override fun createFragment(position: Int): Fragment { + if (position >= 0 && position < attachments.size) { + // Fragment should not wait for or start transition if it already happened but we + // instantiate the same fragment again, e.g. open the first photo, scroll to the + // forth photo and then back to the first. The first fragment will try to start the + // transition and wait until it's over and it will never take place. + val fragment = ViewMediaFragment.newInstance( + attachment = attachments[position], + shouldStartPostponedTransition = !didTransition && position == initialPosition + ) + fragments[position] = WeakReference(fragment) + return fragment + } else { + throw IllegalStateException() + } + } + + override fun onTransitionEnd(position: Int) { + this.didTransition = true + fragments[position]?.get()?.onTransitionEnd() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt new file mode 100644 index 0000000..1e10294 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt @@ -0,0 +1,32 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.util.CustomFragmentStateAdapter + +class MainPagerAdapter(val tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter(activity) { + + override fun createFragment(position: Int): Fragment { + val tab = tabs[position] + return tab.fragment(tab.arguments) + } + + override fun getItemCount() = tabs.size + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt new file mode 100644 index 0000000..d9b9485 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt @@ -0,0 +1,44 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import dagger.android.AndroidInjection +import javax.inject.Inject + +class NotificationClearBroadcastReceiver : BroadcastReceiver() { + + @Inject + lateinit var accountManager: AccountManager + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + + val accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) + + val account = accountManager.getAccountById(accountId) + if (account != null) { + account.activeNotifications = "[]" + accountManager.saveAccount(account) + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt new file mode 100644 index 0000000..2f59a58 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -0,0 +1,167 @@ +/* Copyright 2018 Jeremiasz Nelz + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Message +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.service.SendTootService +import com.keylesspalace.tusky.service.TootToSend +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.service.MessageToSend +import com.keylesspalace.tusky.util.randomAlphanumericString +import dagger.android.AndroidInjection +import javax.inject.Inject + +private const val TAG = "SendStatusBR" + +class SendStatusBroadcastReceiver : BroadcastReceiver() { + + @Inject + lateinit var accountManager: AccountManager + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + + val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1) + val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1) + val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER) + val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME) + val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID) + val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility + val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) + val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) + val citedText = intent.getStringExtra(NotificationHelper.KEY_CITED_TEXT) + val localAuthorId = intent.getStringExtra(NotificationHelper.KEY_CITED_AUTHOR_LOCAL) + val chatId = intent.getStringExtra(NotificationHelper.KEY_CHAT_ID) + + val account = accountManager.getAccountById(senderId) + + val notificationManager = NotificationManagerCompat.from(context) + + if (account == null) { + Log.w(TAG, "Account \"$senderId\" not found in database. Aborting quick reply!") + + val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) + .setSmallIcon(R.drawable.ic_notify) + .setColor(ContextCompat.getColor(context, (R.color.tusky_blue))) + .setGroup(senderFullName) + .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + + builder.setContentTitle(context.getString(R.string.error_generic)) + builder.setContentText(context.getString(R.string.error_sender_account_gone)) + + builder.setSubText(senderFullName) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) + builder.setOnlyAlertOnce(true) + + notificationManager.notify(notificationId, builder.build()) + return + } + + if (intent.action == NotificationHelper.COMPOSE_ACTION) { + context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) + + notificationManager.cancel(notificationId) + + accountManager.setActiveAccount(senderId) + + val composeIntent = ComposeActivity.startIntent(context, ComposeOptions( + inReplyToId = citedStatusId, + replyVisibility = visibility, + contentWarning = spoiler, + mentionedUsernames = mentions.toSet(), + replyingStatusAuthor = localAuthorId, + replyingStatusContent = citedText + )) + + composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + context.startActivity(composeIntent) + } else { + val message = getReplyMessage(intent) + + val sendIntent = if(intent.action == NotificationHelper.REPLY_ACTION) { + val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString() + + SendTootService.sendTootIntent( + context, + TootToSend( + text = text, + warningText = spoiler, + visibility = visibility.serverString(), + sensitive = false, + mediaIds = emptyList(), + mediaUris = emptyList(), + mediaDescriptions = emptyList(), + scheduledAt = null, + inReplyToId = citedStatusId, + poll = null, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + formattingSyntax = "", + preview = false, + accountId = account.id, + savedTootUid = -1, + draftId = -1, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) + ) + } else { + SendTootService.sendMessageIntent(context, + MessageToSend(message.toString(), null, null, account.id, chatId!!, 0)) + } + + context.startService(sendIntent) + + val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) + .setSmallIcon(R.drawable.ic_notify) + .setColor(ContextCompat.getColor(context, (R.color.tusky_blue))) + .setGroup(senderFullName) + .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + + builder.setContentTitle(context.getString(R.string.status_sent)) + builder.setContentText(context.getString(R.string.status_sent_long)) + + builder.setSubText(senderFullName) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) + builder.setOnlyAlertOnce(true) + + notificationManager.notify(notificationId, builder.build()) + } + } + + private fun getReplyMessage(intent: Intent): CharSequence { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + + return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY, "") + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/ChatRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/ChatRepository.kt new file mode 100644 index 0000000..d96df4c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/repository/ChatRepository.kt @@ -0,0 +1,264 @@ +package com.keylesspalace.tusky.repository + +import android.text.SpannedString +import androidx.core.text.parseAsHtml +import androidx.core.text.toHtml +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK +import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.io.IOException +import java.util.* + +typealias ChatStatus = Either +typealias ChatMesssageOrPlaceholder = Either + +interface ChatRepository { + fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, + requestMode: TimelineRequestMode): Single> + + fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single> +} + +class ChatRepositoryImpl( + private val chatsDao: ChatsDao, + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val gson: Gson +) : ChatRepository { + + override fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, + limit: Int, requestMode: TimelineRequestMode + ): Single> { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + return if (requestMode == DISK) { + this.getChatsFromDb(accountId, maxId, sinceId, limit) + } else { + getChatsFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) + } + } + + override fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single> { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + /*return if (requestMode == DISK) { + getChatMessagesFromDb(chatId, accountId, maxId, sinceId, limit) + } else { + getChatMessagesFromNetwork(chatId, maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) + }*/ + + return getChatMessagesFromNetwork(chatId, maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) + } + + private fun getChatsFromNetwork(maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, limit: Int, + accountId: Long, requestMode: TimelineRequestMode + ): Single> { + return mastodonApi.getChats(maxId, null, sinceIdMinusOne, 0, limit + 1) + .map { chats -> + this.saveChatsToDb(accountId, chats, maxId, sinceId) + } + .flatMap { chats -> + this.addFromDbIfNeeded(accountId, chats, maxId, sinceId, limit, requestMode) + } + .onErrorResumeNext { error -> + if (error is IOException && requestMode != NETWORK) { + this.getChatsFromDb(accountId, maxId, sinceId, limit) + } else { + Single.error(error) + } + } + } + + private fun getChatMessagesFromNetwork(chatId: String, maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, limit: Int, + accountId: Long, requestMode: TimelineRequestMode + ): Single> { + return mastodonApi.getChatMessages(chatId, maxId, null, sinceIdMinusOne, 0, limit + 1).map { + it.mapTo(mutableListOf(), ChatMessage::lift) + } + } + + + private fun addFromDbIfNeeded(accountId: Long, chats: List, + maxId: String?, sinceId: String?, limit: Int, + requestMode: TimelineRequestMode + ): Single> { + return if (requestMode != NETWORK && chats.size < 2) { + val newMaxID = if (chats.isEmpty()) { + maxId + } else { + chats.last { it.isRight() }.asRight().id + } + this.getChatsFromDb(accountId, newMaxID, sinceId, limit) + .map { fromDb -> + // If it's just placeholders and less than limit (so we exhausted both + // db and server at this point) + if (fromDb.size < limit && fromDb.all { !it.isRight() }) { + chats + } else { + chats + fromDb + } + } + } else { + Single.just(chats) + } + } + + private fun getChatsFromDb(accountId: Long, maxId: String?, sinceId: String?, + limit: Int): Single> { + return chatsDao.getChatsForAccount(accountId, maxId, sinceId, limit) + .subscribeOn(Schedulers.io()) + .map { chats -> + chats.map { it.toChat(gson) } + } + } + + + private fun saveChatsToDb(accountId: Long, chats: List, + maxId: String?, sinceId: String? + ): List { + var placeholderToInsert: Placeholder? = null + + // Look for overlap + val resultChats = if (chats.isNotEmpty() && sinceId != null) { + val indexOfSince = chats.indexOfLast { it.id == sinceId } + if (indexOfSince == -1) { + // We didn't find the status which must be there. Add a placeholder + placeholderToInsert = Placeholder(sinceId.inc()) + chats.mapTo(mutableListOf(), Chat::lift) + .apply { + add(Either.Left(placeholderToInsert)) + } + } else { + // There was an overlap. Remove all overlapped statuses. No need for a placeholder. + chats.mapTo(mutableListOf(), Chat::lift) + .apply { + subList(indexOfSince, size).clear() + } + } + } else { + // Just a normal case. + chats.map(Chat::lift) + } + + Single.fromCallable { + + if(chats.isNotEmpty()) { + chatsDao.deleteRange(accountId, chats.last().id, chats.first().id) + } + + for (chat in chats) { + val pair = chat.toEntity(accountId, gson) + + chatsDao.insertInTransaction( + pair.first, + pair.second, + chat.account.toEntity(accountId, gson) + ) + } + + placeholderToInsert?.let { + chatsDao.insertChatIfNotThere(it.toChatEntity(accountId)) + } + + // If we're loading in the bottom insert placeholder after every load + // (for requests on next launches) but not return it. + if (sinceId == null && chats.isNotEmpty()) { + chatsDao.insertChatIfNotThere( + Placeholder(chats.last().id.dec()).toChatEntity(accountId)) + } + + // There may be placeholders which we thought could be from our TL but they are not + if (chats.size > 2) { + chatsDao.removeAllPlaceholdersBetween(accountId, chats.first().id, + chats.last().id) + } else if (placeholderToInsert == null && maxId != null && sinceId != null) { + chatsDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + + return resultChats + } +} + +private val emojisListTypeToken = object : TypeToken>() {} + +fun Placeholder.toChatEntity(timelineUserId: Long): ChatEntity { + return ChatEntity( + localId = timelineUserId, + chatId = this.id, + accountId = "", + unread = 0L, + updatedAt = 0L, + lastMessageId = null + ) +} + +fun ChatMessage.toEntity(timelineUserId: Long, gson: Gson) : ChatMessageEntity { + return ChatMessageEntity( + localId = timelineUserId, + messageId = this.id, + content = this.content?.toHtml(), + chatId = this.chatId, + accountId = this.accountId, + createdAt = this.createdAt.time, + attachment = this.attachment?.let { gson.toJson(it, Attachment::class.java) }, + emojis = gson.toJson(this.emojis) + ) +} + +fun Chat.toEntity(timelineUserId: Long, gson: Gson): Pair { + return Pair(ChatEntity( + localId = timelineUserId, + chatId = this.id, + accountId = this.account.id, + unread = this.unread, + updatedAt = this.updatedAt.time, + lastMessageId = this.lastMessage?.id + ), this.lastMessage?.toEntity(timelineUserId, gson)) +} + +fun ChatMessageEntity.toChatMessage(gson: Gson) : ChatMessage { + return ChatMessage( + id = this.messageId, + content = this.content?.let { it.parseAsHtml().trimTrailingWhitespace() }, + chatId = this.chatId, + accountId = this.accountId, + createdAt = Date(this.createdAt), + attachment = this.attachment?.let { gson.fromJson(it, Attachment::class.java) }, + emojis = gson.fromJson(this.emojis, object : TypeToken>() {}.type ), + card = null /* don't care about card */ + ) +} + +fun ChatEntityWithAccount.toChat(gson: Gson) : ChatStatus { + if(account == null || chat.accountId.isEmpty() || chat.updatedAt == 0L) + return Either.Left(Placeholder(chat.chatId)) + + return Chat( + account = this.account?.toAccount(gson) ?: Account("", "", "", "", SpannedString(""), "", "", "" ), + id = this.chat.chatId, + unread = this.chat.unread, + updatedAt = Date(this.chat.updatedAt), + lastMessage = this.lastMessage?.toChatMessage(gson) + ).lift() +} + +fun ChatMessage.lift(): ChatMesssageOrPlaceholder = Either.Right(this) + +fun Chat.lift(): ChatStatus = Either.Right(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt new file mode 100644 index 0000000..58233f3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt @@ -0,0 +1,393 @@ +package com.keylesspalace.tusky.repository + +import android.text.SpannedString +import androidx.core.text.parseAsHtml +import androidx.core.text.toHtml +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK +import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.collections.ArrayList + +data class Placeholder(val id: String) + +typealias TimelineStatus = Either + +enum class TimelineRequestMode { + DISK, NETWORK, ANY +} + +interface TimelineRepository { + fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, + requestMode: TimelineRequestMode): Single> + + companion object { + val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) + } +} + +class TimelineRepositoryImpl( + private val timelineDao: TimelineDao, + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val gson: Gson +) : TimelineRepository { + + init { + this.cleanup() + } + + override fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, + limit: Int, requestMode: TimelineRequestMode + ): Single> { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + return if (requestMode == DISK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) + } + } + + private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, limit: Int, + accountId: Long, requestMode: TimelineRequestMode + ): Single> { + return mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1) + .map { statuses -> + this.saveStatusesToDb(accountId, statuses, maxId, sinceId) + } + .flatMap { statuses -> + this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) + } + .onErrorResumeNext { error -> + if (error is IOException && requestMode != NETWORK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + Single.error(error) + } + } + } + + private fun addFromDbIfNeeded(accountId: Long, statuses: List>, + maxId: String?, sinceId: String?, limit: Int, + requestMode: TimelineRequestMode + ): Single>? { + return if (requestMode != NETWORK && statuses.size < 2) { + val newMaxID = if (statuses.isEmpty()) { + maxId + } else { + statuses.last { it.isRight() }.asRight().id + } + this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) + .map { fromDb -> + // If it's just placeholders and less than limit (so we exhausted both + // db and server at this point) + if (fromDb.size < limit && fromDb.all { !it.isRight() }) { + statuses + } else { + statuses + fromDb + } + } + } else { + Single.just(statuses) + } + } + + private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?, + limit: Int): Single> { + return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) + .subscribeOn(Schedulers.io()) + .map { statuses -> + statuses.map { it.toStatus() } + } + } + + private fun saveStatusesToDb(accountId: Long, statuses: List, + maxId: String?, sinceId: String? + ): List> { + var placeholderToInsert: Placeholder? = null + + // Look for overlap + val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { + val indexOfSince = statuses.indexOfLast { it.id == sinceId } + if (indexOfSince == -1) { + // We didn't find the status which must be there. Add a placeholder + placeholderToInsert = Placeholder(sinceId.inc()) + statuses.mapTo(mutableListOf(), Status::lift) + .apply { + add(Either.Left(placeholderToInsert)) + } + } else { + // There was an overlap. Remove all overlapped statuses. No need for a placeholder. + statuses.mapTo(mutableListOf(), Status::lift) + .apply { + subList(indexOfSince, size).clear() + } + } + } else { + // Just a normal case. + statuses.map(Status::lift) + } + + Single.fromCallable { + + if(statuses.isNotEmpty()) { + timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) + } + + for (status in statuses) { + timelineDao.insertInTransaction( + status.toEntity(accountId, gson), + status.account.toEntity(accountId, gson), + status.reblog?.account?.toEntity(accountId, gson) + ) + } + + placeholderToInsert?.let { + timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) + } + + // If we're loading in the bottom insert placeholder after every load + // (for requests on next launches) but not return it. + if (sinceId == null && statuses.isNotEmpty()) { + timelineDao.insertStatusIfNotThere( + Placeholder(statuses.last().id.dec()).toEntity(accountId)) + } + + // There may be placeholders which we thought could be from our TL but they are not + if (statuses.size > 2) { + timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, + statuses.last().id) + } else if (placeholderToInsert == null && maxId != null && sinceId != null) { + timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + + return resultStatuses + } + + private fun cleanup() { + Schedulers.io().scheduleDirect { + val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL + timelineDao.cleanup(olderThan) + } + } + + private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { + if (this.status.authorServerId == null) { + return Either.Left(Placeholder(this.status.serverId)) + } + + val attachments: ArrayList = gson.fromJson(status.attachments, + object : TypeToken>() {}.type) ?: ArrayList() + val mentions: Array = gson.fromJson(status.mentions, + Array::class.java) ?: arrayOf() + val application = gson.fromJson(status.application, Status.Application::class.java) + val emojis: List = gson.fromJson(status.emojis, + object : TypeToken>() {}.type) ?: listOf() + val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) + val pleroma = gson.fromJson(status.pleroma, Status.PleromaStatus::class.java) + + val reblog = status.reblogServerId?.let { id -> + Status( + id = id, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + poll = poll, + card = null, + pleroma = pleroma + ) + } + val status = if (reblog != null) { + Status( + id = status.serverId, + url = null, // no url for reblogs + account = this.reblogAccount!!.toAccount(gson), + inReplyToId = null, + inReplyToAccountId = null, + reblog = reblog, + content = SpannedString(""), + createdAt = Date(status.createdAt), // lie but whatever? + emojis = listOf(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = status.visibility!!, + attachments = ArrayList(), + mentions = arrayOf(), + application = null, + pinned = false, + poll = null, + card = null, + pleroma = null + ) + } else { + Status( + id = status.serverId, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + poll = poll, + card = null, + pleroma = pleroma + ) + } + return Either.Right(status) + } +} + +private val emojisListTypeToken = object : TypeToken>() {} + +fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { + return TimelineAccountEntity( + serverId = id, + timelineUserId = accountId, + localUsername = localUsername, + username = username, + displayName = displayName.orEmpty(), + url = url, + avatar = avatar, + emojis = gson.toJson(emojis), + bot = bot + ) +} + +fun TimelineAccountEntity.toAccount(gson: Gson): Account { + return Account( + id = serverId, + localUsername = localUsername, + username = username, + displayName = displayName, + note = SpannedString(""), + url = url, + avatar = avatar, + header = "", + locked = false, + followingCount = 0, + followersCount = 0, + statusesCount = 0, + source = null, + bot = bot, + emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), + fields = null, + moved = null + ) +} + + +fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = this.id, + url = null, + timelineUserId = timelineUserId, + authorServerId = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + pleroma = null + ) +} + +fun Status.toEntity(timelineUserId: Long, + gson: Gson): TimelineStatusEntity { + val actionable = actionableStatus + return TimelineStatusEntity( + serverId = this.id, + url = actionable.url!!, + timelineUserId = timelineUserId, + authorServerId = actionable.account.id, + inReplyToId = actionable.inReplyToId, + inReplyToAccountId = actionable.inReplyToAccountId, + content = actionable.content.toHtml(), + createdAt = actionable.createdAt.time, + emojis = actionable.emojis.let(gson::toJson), + reblogsCount = actionable.reblogsCount, + favouritesCount = actionable.favouritesCount, + reblogged = actionable.reblogged, + favourited = actionable.favourited, + bookmarked = actionable.bookmarked, + sensitive = actionable.sensitive, + spoilerText = actionable.spoilerText, + visibility = actionable.visibility, + attachments = actionable.attachments.let(gson::toJson), + mentions = actionable.mentions.let(gson::toJson), + application = actionable.application.let(gson::toJson), + reblogServerId = reblog?.id, + reblogAccountId = reblog?.let { this.account.id }, + poll = actionable.poll.let(gson::toJson), + pleroma = actionable.pleroma.let(gson::toJson) + ) +} + +fun Status.lift(): Either = Either.Right(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt new file mode 100644 index 0000000..cbda4b1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -0,0 +1,435 @@ +package com.keylesspalace.tusky.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ClipData +import android.content.ClipDescription +import android.content.Context +import android.content.Intent +import android.os.* +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.drafts.DraftHelper +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.SaveTootHelper +import dagger.android.AndroidInjection +import kotlinx.android.parcel.Parcelize +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class SendTootService : Service(), Injectable { + + @Inject + lateinit var mastodonApi: MastodonApi + @Inject + lateinit var accountManager: AccountManager + @Inject + lateinit var eventHub: EventHub + @Inject + lateinit var database: AppDatabase + @Inject + lateinit var draftHelper: DraftHelper + @Inject + lateinit var saveTootHelper: SaveTootHelper + + private val tootsToSend = ConcurrentHashMap() + private val sendCalls = ConcurrentHashMap, Call>>() + + private val timer = Timer() + + private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } + + override fun onCreate() { + AndroidInjection.inject(this) + super.onCreate() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent.hasExtra(KEY_CANCEL)) { + cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) + return START_NOT_STICKY + } + + val postToSend : PostToSend = (intent.getParcelableExtra(KEY_TOOT) + ?: intent.getParcelableExtra(KEY_CHATMSG)) as PostToSend? + ?: throw IllegalStateException("SendTootService started without $KEY_CHATMSG or $KEY_TOOT extra") + + if (NotificationHelper.NOTIFICATION_USE_CHANNELS) { + val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW) + notificationManager.createNotificationChannel(channel) + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_title)) + .setContentText(postToSend.getNotificationText()) + .setProgress(1, 0, true) + .setOngoing(true) + .setColor(ContextCompat.getColor(this, R.color.tusky_blue)) + .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) + + if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + startForeground(sendingNotificationId, builder.build()) + } else { + notificationManager.notify(sendingNotificationId, builder.build()) + } + + tootsToSend[sendingNotificationId] = postToSend + sendToot(sendingNotificationId--) + + return START_NOT_STICKY + } + + private fun sendToot(tootId: Int) { + + // when tootToSend == null, sending has been canceled + val postToSend = tootsToSend[tootId] ?: return + + // when account == null, user has logged out, cancel sending + val account = accountManager.getAccountById(postToSend.getAccountId()) + + if (account == null) { + tootsToSend.remove(tootId) + notificationManager.cancel(tootId) + stopSelfWhenDone() + return + } + + postToSend.incrementRetries() + + if(postToSend is TootToSend) { + val contentType : String? = if(postToSend.formattingSyntax.isNotEmpty()) postToSend.formattingSyntax else null + val preview : Boolean? = if(postToSend.preview) true else null + + val newStatus = NewStatus( + postToSend.text, + postToSend.warningText, + postToSend.inReplyToId, + postToSend.visibility, + postToSend.sensitive, + postToSend.mediaIds, + postToSend.scheduledAt, + postToSend.poll, + contentType, + preview + ) + + val sendCall = mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + postToSend.idempotencyKey, + newStatus + ) + + val callback = object : Callback { + override fun onResponse(call: Call, response: Response) { + + val scheduled = !postToSend.scheduledAt.isNullOrEmpty() + tootsToSend.remove(tootId) + + if (response.isSuccessful) { + // If the status was loaded from a draft, delete the draft and associated media files. + if (postToSend.savedTootUid != 0) { + saveTootHelper.deleteDraft(postToSend.savedTootUid) + } + if (postToSend.draftId != 0) { + draftHelper.deleteDraftAndAttachments(postToSend.draftId) + .subscribe() + } + + when { + postToSend.preview -> response.body()?.let(::StatusPreviewEvent)?.let(eventHub::dispatch) + scheduled -> response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) + else -> response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch) + } + notificationManager.cancel(tootId) + + } else { + // the server refused to accept the toot, save toot & show error message + saveTootToDrafts(postToSend) + + val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_error_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) + + notificationManager.cancel(tootId) + notificationManager.notify(errorNotificationId--, builder.build()) + + } + + stopSelfWhenDone() + + } + + override fun onFailure(call: Call, t: Throwable) { + var backoff = TimeUnit.SECONDS.toMillis(postToSend.retries.toLong()) + if (backoff > MAX_RETRY_INTERVAL) { + backoff = MAX_RETRY_INTERVAL + } + + timer.schedule(object : TimerTask() { + override fun run() { + sendToot(tootId) + } + }, backoff) + } + } + + sendCalls[tootId] = Either.Left(sendCall) + sendCall.enqueue(callback) + } else if(postToSend is MessageToSend) { + val newMessage = NewChatMessage(postToSend.text, postToSend.mediaId) + + val sendCall = mastodonApi.createChatMessage( + "Bearer " + account.accessToken, + account.domain, + postToSend.chatId, + newMessage + ) + + val callback = object : Callback { + override fun onResponse(call: Call, response: Response) { + tootsToSend.remove(tootId) + + if (response.isSuccessful) { + notificationManager.cancel(tootId) + + eventHub.dispatch(ChatMessageDeliveredEvent(response.body()!!)) + } else { + val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_error_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) + + notificationManager.cancel(tootId) + notificationManager.notify(errorNotificationId--, builder.build()) + } + + stopSelfWhenDone() + } + + override fun onFailure(call: Call, t: Throwable) { + var backoff = TimeUnit.SECONDS.toMillis(postToSend.retries.toLong()) + if (backoff > MAX_RETRY_INTERVAL) { + backoff = MAX_RETRY_INTERVAL + } + + timer.schedule(object : TimerTask() { + override fun run() { + sendToot(tootId) + } + }, backoff) + } + } + + sendCalls[tootId] = Either.Right(sendCall) + sendCall.enqueue(callback) + } + } + + private fun stopSelfWhenDone() { + + if (tootsToSend.isEmpty()) { + ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun cancelSending(tootId: Int) { + val tootToCancel = tootsToSend.remove(tootId) + if (tootToCancel != null) { + val sendCall = sendCalls.remove(tootId) + + sendCall?.let { + if(it.isLeft()) { + val sendStatusCall = it.asLeft() + sendStatusCall.cancel() + + saveTootToDrafts(tootToCancel as TootToSend) + } else { + val sendMessageCall = it.asRight() + sendMessageCall.cancel() + } + } + + val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_cancel_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) + + notificationManager.notify(tootId, builder.build()) + + timer.schedule(object : TimerTask() { + override fun run() { + notificationManager.cancel(tootId) + stopSelfWhenDone() + } + }, 5000) + + } + } + + private fun saveTootToDrafts(toot: TootToSend) { + + draftHelper.saveDraft( + draftId = toot.draftId, + accountId = toot.getAccountId(), + inReplyToId = toot.inReplyToId, + content = toot.text, + contentWarning = toot.warningText, + sensitive = toot.sensitive, + visibility = Status.Visibility.byString(toot.visibility), + mediaUris = toot.mediaUris, + mediaDescriptions = toot.mediaDescriptions, + poll = toot.poll, + formattingSyntax = toot.formattingSyntax, + failedToSend = true + ).subscribe() + } + + private fun cancelSendingIntent(tootId: Int): PendingIntent { + + val intent = Intent(this, SendTootService::class.java) + + intent.putExtra(KEY_CANCEL, tootId) + + return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + + companion object { + + private const val KEY_CHATMSG = "chatmsg" + private const val KEY_TOOT = "toot" + private const val KEY_CANCEL = "cancel_id" + private const val CHANNEL_ID = "send_toots" + + private val MAX_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(1) + + private var sendingNotificationId = -1 // use negative ids to not clash with other notis + private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis + + private fun Intent.forwardUriPermissions(mediaUris: List) { + if(mediaUris.isEmpty()) + return + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val uriClip = ClipData( + ClipDescription("Toot Media", arrayOf("image/*", "video/*")), + ClipData.Item(mediaUris[0]) + ) + mediaUris.drop(1).forEach { uriClip.addItem(ClipData.Item(it)) } + + clipData = uriClip + } + + @JvmStatic + fun sendMessageIntent(context: Context, msgToSend: MessageToSend): Intent { + val intent = Intent(context, SendTootService::class.java) + intent.putExtra(KEY_CHATMSG, msgToSend) + if(msgToSend.mediaUri != null) + intent.forwardUriPermissions(listOf(msgToSend.mediaUri)) + + return intent + } + + @JvmStatic + fun sendTootIntent(context: Context, tootToSend: TootToSend): Intent { + val intent = Intent(context, SendTootService::class.java) + intent.putExtra(KEY_TOOT, tootToSend) + intent.forwardUriPermissions(tootToSend.mediaUris) + + return intent + } + + } +} + +interface PostToSend { + fun getAccountId() : Long + fun getNotificationText() : String + fun incrementRetries() +} + +@Parcelize +data class MessageToSend( + val text: String, + val mediaId: String?, + val mediaUri: String?, + private val accountId: Long, + val chatId: String, + var retries: Int +) : Parcelable, PostToSend { + override fun getAccountId(): Long { + return accountId + } + + override fun getNotificationText() : String { + return text + } + + override fun incrementRetries() { + retries++ + } +} + +@Parcelize +data class TootToSend( + val text: String, + val warningText: String, + val visibility: String, + val sensitive: Boolean, + val mediaIds: List, + val mediaUris: List, + val mediaDescriptions: List, + val scheduledAt: String?, + val inReplyToId: String?, + val poll: NewPoll?, + val replyingStatusContent: String?, + val replyingStatusAuthorUsername: String?, + val formattingSyntax: String, + val preview: Boolean, + private val accountId: Long, + val savedTootUid: Int, + val draftId: Int, + val idempotencyKey: String, + var retries: Int +) : Parcelable, PostToSend { + override fun getNotificationText() : String { + return if(warningText.isBlank()) text else warningText + } + + override fun getAccountId(): Long { + return accountId + } + + override fun incrementRetries() { + retries++ + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt new file mode 100644 index 0000000..8fff6e4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -0,0 +1,46 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.service + +import android.content.Context +import android.content.Intent +import android.os.Build + +interface ServiceClient { + fun sendToot(tootToSend: TootToSend) + + fun sendChatMessage(msgToSend: MessageToSend) +} + +class ServiceClientImpl(private val context: Context) : ServiceClient { + private fun startService(intent: Intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + override fun sendToot(tootToSend: TootToSend) { + val intent = SendTootService.sendTootIntent(context, tootToSend) + startService(intent) + } + + override fun sendChatMessage(msgToSend: MessageToSend) { + val intent = SendTootService.sendMessageIntent(context, msgToSend) + startService(intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt b/app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt new file mode 100644 index 0000000..16d3491 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt @@ -0,0 +1,239 @@ +package com.keylesspalace.tusky.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.google.gson.Gson +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.ChatMessageReceivedEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.StreamEvent +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isLessThan +import dagger.android.AndroidInjection +import okhttp3.* +import javax.inject.Inject + +class StreamingService: Service(), Injectable { + @Inject + lateinit var api: MastodonApi + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var gson: Gson + + @Inject + lateinit var client: OkHttpClient + + private val sockets: MutableMap = mutableMapOf() + + private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onCreate() { + AndroidInjection.inject(this) + super.onCreate() + } + + private fun stopStreamingForId(id: Long) { + if(id in sockets) { + sockets[id]!!.close(1000, null) + sockets.remove(id) + } + } + + private fun stopStreaming() { + for(sock in sockets) { + sock.value.close(1000, null) + } + sockets.clear() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + } + + notificationManager.cancel(1337) + + synchronized(serviceRunning) { + serviceRunning = false + } + } + + override fun onDestroy() { + stopStreaming() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if(intent.getBooleanExtra(KEY_STOP_STREAMING, false)) { + Log.d(TAG, "Stream goes suya..") + stopStreaming() + stopSelfResult(startId) + return START_NOT_STICKY + } + + var description = getString(R.string.streaming_notification_description) + val accounts = accountManager.getAllAccountsOrderedByActive() + var count = 0 + for(account in accounts) { + stopStreamingForId(account.id) + + if(!account.notificationsStreamingEnabled) + continue + + val endpoint = "wss://${account.domain}/api/v1/streaming/?access_token=${account.accessToken}&stream=user:notification" + val request = Request.Builder().url(endpoint).build() + + Log.d(TAG, "Running stream for ${account.fullName}") + + sockets[account.id] = client.newWebSocket( + request, + makeStreamingListener( + "${account.fullName}/user:notification", + account + ) + ) + + description += "\n" + account.fullName + count++ + } + + if(count <= 0) { + Log.d(TAG, "No accounts. Stopping stream") + stopStreaming() + stopSelfResult(startId) + return START_NOT_STICKY + } + + if (NotificationHelper.NOTIFICATION_USE_CHANNELS) { + val channel = NotificationChannel(CHANNEL_ID, getString(R.string.streaming_notification_name), NotificationManager.IMPORTANCE_LOW) + notificationManager.createNotificationChannel(channel) + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.streaming_notification_name)) + .setContentText(description) + .setOngoing(true) + .setNotificationSilent() + .setPriority(NotificationCompat.PRIORITY_MIN) + .setColor(ContextCompat.getColor(this, R.color.tusky_blue)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + startForeground(1337, builder.build()) + } else { + notificationManager.notify(1337, builder.build()) + } + + synchronized(serviceRunning) { + serviceRunning = true + } + + return START_NOT_STICKY + } + + companion object { + val CHANNEL_ID = "streaming" + val KEY_STOP_STREAMING = "stop_streaming" + val TAG = "StreamingService" + + @JvmStatic + var serviceRunning = false + + @JvmStatic + private fun startForegroundService(ctx: Context, intent: Intent) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent) + } else { + ctx.startService(intent) + } + } + + @JvmStatic + fun startStreaming(context: Context) { + val intent = Intent(context, StreamingService::class.java) + intent.putExtra(KEY_STOP_STREAMING, false) + + Log.d(TAG, "Starting notifications streaming service...") + + startForegroundService(context, intent) + } + + @JvmStatic + fun stopStreaming(context: Context) { + synchronized(serviceRunning) { + if(!serviceRunning) + return + + val intent = Intent(context, StreamingService::class.java) + intent.putExtra(KEY_STOP_STREAMING, true) + + Log.d(TAG, "Stopping notifications streaming service...") + + serviceRunning = false + + startForegroundService(context, intent) + } + } + } + + private fun makeStreamingListener(tag: String, account: AccountEntity) : WebSocketListener { + return object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "Stream connected to: $tag") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "Stream closed for: $tag") + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.d(TAG, "Stream failed for $tag", t) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val event = gson.fromJson(text, StreamEvent::class.java) + when(event.event) { + StreamEvent.EventType.NOTIFICATION -> { + val notification = gson.fromJson(event.payload, Notification::class.java) + NotificationHelper.make(this@StreamingService, notification, account, true) + + if(notification.type == Notification.Type.CHAT_MESSAGE) { + eventHub.dispatch(ChatMessageReceivedEvent(notification.chatMessage!!)) + } + + if(account.lastNotificationId.isLessThan(notification.id)) { + account.lastNotificationId = notification.id + accountManager.saveAccount(account) + } + } + else -> { + Log.d(TAG, "Unknown event type: ${event.event}") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt new file mode 100644 index 0000000..1e170da --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -0,0 +1,39 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.service + +import android.annotation.TargetApi +import android.content.Intent +import android.service.quicksettings.TileService +import com.keylesspalace.tusky.MainActivity + +/** + * Small Addition that adds in a QuickSettings tile + * opens the Compose activity or shows an account selector when multiple accounts are present + */ + +@TargetApi(24) +class TuskyTileService : TileService() { + + override fun onClick() { + val intent = Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + action = Intent.ACTION_SEND + type = "text/plain" + } + startActivityAndCollapse(intent) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt new file mode 100644 index 0000000..b8ad946 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -0,0 +1,74 @@ +package com.keylesspalace.tusky.settings + +enum class AppTheme(val value: String) { + NIGHT("night"), + DAY("day"), + BLACK("black"), + AUTO("auto"), + AUTO_SYSTEM("auto_system"); + + companion object { + fun stringValues() = values().map { it.value }.toTypedArray() + } +} + +object PrefKeys { + // Note: not all of these keys are actually used as SharedPreferences keys but we must give + // each preference a key for it to work. + + const val APP_THEME = "appTheme" + const val EMOJI = "selected_emoji_font" + const val FAB_HIDE = "fabHide" + const val LANGUAGE = "language" + const val STATUS_TEXT_SIZE = "statusTextSize" + const val MAIN_NAV_POSITION = "mainNavPosition" + const val HIDE_TOP_TOOLBAR = "hideTopToolbar" + const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" + const val SHOW_BOT_OVERLAY = "showBotOverlay" + const val ANIMATE_GIF_AVATARS = "animateGifAvatars" + const val USE_BLURHASH = "useBlurhash" + const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" + const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" + const val CONFIRM_REBLOGS = "confirmReblogs" + const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" + const val BIG_EMOJIS = "bigEmojis" + const val STICKERS = "stickers" + const val ANONYMIZE_FILENAMES = "anonymizeFilenames" + const val HIDE_MUTED_USERS = "hideMutedUsers" + const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis" + const val RENDER_STATUS_AS_MENTION = "renderStatusAsMention" + + const val CUSTOM_TABS = "customTabs" + const val WELLBEING_LIMITED_NOTIFICATIONS = "wellbeingModeLimitedNotifications" + const val WELLBEING_HIDE_STATS_POSTS = "wellbeingHideStatsPosts" + const val WELLBEING_HIDE_STATS_PROFILE = "wellbeingHideStatsProfile" + + const val HTTP_PROXY_ENABLED = "httpProxyEnabled" + const val HTTP_PROXY_SERVER = "httpProxyServer" + const val HTTP_PROXY_PORT = "httpProxyPort" + + const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy" + const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity" + const val DEFAULT_FORMATTING_SYNTAX = "defaultFormattingSyntax" + const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled" + const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia" + const val ALWAYS_OPEN_SPOILER = "alwaysOpenSpoiler" + const val LIVE_NOTIFICATIONS = "liveNotifications" + + const val NOTIFICATIONS_ENABLED = "notificationsEnabled" + const val NOTIFICATION_ALERT_LIGHT = "notificationAlertLight" + const val NOTIFICATION_ALERT_VIBRATE = "notificationAlertVibrate" + const val NOTIFICATION_ALERT_SOUND = "notificationAlertSound" + const val NOTIFICATION_FILTER_POLLS = "notificationFilterPolls" + const val NOTIFICATION_FILTER_CHAT_MESSAGES = "notificationFilterChatMessages" + const val NOTIFICATION_FILTER_FAVS = "notificationFilterFavourites" + const val NOTIFICATION_FILTER_REBLOGS = "notificationFilterReblogs" + const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" + const val NOTIFICATION_FILTER_EMOJI_REACTIONS = "notificationFilterEmojis" + const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" + const val NOTIFICATION_FILTER_MOVE = "notificationFilterMove" + const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" + + const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" + const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" +} diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt new file mode 100644 index 0000000..82dfa14 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -0,0 +1,84 @@ +package com.keylesspalace.tusky.settings + +import android.content.Context +import androidx.annotation.StringRes +import androidx.preference.* +import com.keylesspalace.tusky.components.preference.EmojiPreference +import okhttp3.OkHttpClient + +class PreferenceParent( + val context: Context, + val addPref: (pref: Preference) -> Unit +) + +inline fun PreferenceParent.preference(builder: Preference.() -> Unit): Preference { + val pref = Preference(context) + builder(pref) + addPref(pref) + return pref +} + +inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): ListPreference { + val pref = ListPreference(context) + builder(pref) + addPref(pref) + return pref +} + +inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference { + val pref = EmojiPreference(context, okHttpClient) + builder(pref) + addPref(pref) + return pref +} + +inline fun PreferenceParent.switchPreference( + builder: SwitchPreference.() -> Unit +): SwitchPreference { + val pref = SwitchPreference(context) + builder(pref) + addPref(pref) + return pref +} + +inline fun PreferenceParent.editTextPreference( + builder: EditTextPreference.() -> Unit +): EditTextPreference { + val pref = EditTextPreference(context) + builder(pref) + addPref(pref) + return pref +} + +inline fun PreferenceParent.checkBoxPreference( + builder: CheckBoxPreference.() -> Unit +): CheckBoxPreference { + val pref = CheckBoxPreference(context) + builder(pref) + addPref(pref) + return pref +} + +inline fun PreferenceParent.preferenceCategory( + @StringRes title: Int, + builder: PreferenceParent.(PreferenceCategory) -> Unit +) { + val category = PreferenceCategory(context) + addPref(category) + category.setTitle(title) + val newParent = PreferenceParent(context) { category.addPreference(it) } + builder(newParent, category) +} + +inline fun PreferenceFragmentCompat.makePreferenceScreen( + builder: PreferenceParent.() -> Unit +): PreferenceScreen { + val context = requireContext() + val screen = preferenceManager.createPreferenceScreen(context) + val parent = PreferenceParent(context) { screen.addPreference(it) } + // For some functions (like dependencies) it's much easier for us if we attach screen first + // and change it later + preferenceScreen = screen + builder(parent) + return screen +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BBCodeEdit.java b/app/src/main/java/com/keylesspalace/tusky/util/BBCodeEdit.java new file mode 100644 index 0000000..f67e3ad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BBCodeEdit.java @@ -0,0 +1,83 @@ +package com.keylesspalace.tusky.util; + +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import android.text.Editable; +import android.text.Selection; +import android.text.Spannable; +import android.widget.EditText; +import me.thanel.markdownedit.SelectionUtils; + +public class BBCodeEdit { + private BBCodeEdit() { /* cannot be instantiated */ } + + public static void addBold(@NonNull Editable text) { + HTMLEdit.surroundSelectionWith(text, "[b]", "[/b]"); + } + + public static void addBold(@NonNull EditText editText) { + addBold(editText.getText()); + } + + public static void addItalic(@NonNull Editable text) { + HTMLEdit.surroundSelectionWith(text, "[i]", "[/i]"); + } + + public static void addItalic(@NonNull EditText editText) { + addItalic(editText.getText()); + } + + public static void addStrikeThrough(@NonNull Editable text) { + HTMLEdit.surroundSelectionWith(text, "[s]", "[/s]"); + } + + public static void addStrikeThrough(@NonNull EditText editText) { + addStrikeThrough(editText.getText()); + } + + public static void addLink(@NonNull Editable text) { + if (!SelectionUtils.hasSelection(text)) { + SelectionUtils.selectWordAroundCursor(text); + } + String selectedText = SelectionUtils.getSelectedText(text).toString().trim(); + + int selectionStart = SelectionUtils.getSelectionStart(text); + + String begin = "[url=url]"; + String end = "[/url]"; + String result = begin + selectedText + end; + SelectionUtils.replaceSelectedText(text, result); + + if (selectedText.length() == 0) { + Selection.setSelection(text, selectionStart + begin.length()); + } else { + selectionStart = selectionStart + 5; // [url=".length() + Selection.setSelection(text, selectionStart, selectionStart + 3); + } + } + + public static void addLink(@NonNull EditText editText) { + addLink(editText.getText()); + } + + /** + * Inserts a markdown code block to the specified EditText at the currently selected position. + * + * @param text The {@link Editable} view to which to add markdown code block. + */ + public static void addCode(@NonNull Editable text) { + HTMLEdit.surroundSelectionWith(text, "[code]", "[/code]"); + } + + /** + * Inserts a markdown code block to the specified EditText at the currently selected position. + * + * @param editText The {@link EditText} view to which to add markdown code block. + */ + public static void addCode(@NonNull EditText editText) { + addCode(editText.getText()); + } +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt new file mode 100644 index 0000000..dad6d55 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2017 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 com.keylesspalace.tusky.util + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList + +/** + * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system + */ +data class BiListing( + // the LiveData of paged lists for the UI to observe + val pagedList: LiveData>, + // represents the network request status for load data before first to show to the user + val networkStateBefore: LiveData, + // represents the network request status for load data after last to show to the user + val networkStateAfter: LiveData, + // represents the refresh status to show to the user. Separate from networkState, this + // value is importantly only when refresh is requested. + val refreshState: LiveData, + // refreshes the whole data and fetches it from scratch. + val refresh: () -> Unit, + // retries any failed requests. + val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt new file mode 100644 index 0000000..14aee81 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt @@ -0,0 +1,8 @@ +package com.keylesspalace.tusky.util + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class BindingViewHolder( + val binding: T +) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt new file mode 100644 index 0000000..bd5f900 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt @@ -0,0 +1,130 @@ +/** + * Blurhash implementation from blurhash project: + * https://github.com/woltapp/blurhash + * Minor modifications by charlag + */ + +package com.keylesspalace.tusky.util + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoder { + + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { + require(width > 0) { "Width must be greater than zero" } + require(height > 0) { "height must be greater than zero" } + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, height: Int, + numCompX: Int, numCompY: Int, + colors: Array + ): Bitmap { + val imageArray = IntArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val basis = (cos(PI * x * i / width) * cos(PI * y * j / height)).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt b/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt new file mode 100644 index 0000000..2cf2348 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt @@ -0,0 +1,7 @@ +package com.keylesspalace.tusky.util + +enum class CardViewMode { + NONE, + FULL_WIDTH, + INDENTED +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt b/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt new file mode 100644 index 0000000..a9e7ba8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.util + +import android.text.TextPaint +import android.text.style.ClickableSpan + +abstract class ClickableSpanNoUnderline : ClickableSpan() { + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt new file mode 100644 index 0000000..7a4ee4d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt @@ -0,0 +1,108 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.text.SpannableString +import android.text.Spanned +import android.text.TextUtils +import android.widget.MultiAutoCompleteTextView + +class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { + + private fun isMentionOrHashtagAllowedCharacter(character: Char) : Boolean { + return Character.isLetterOrDigit(character) || character == '_' // simple usernames + || character == '-' // extended usernames + || character == '.' // domain dot + } + + override fun findTokenStart(text: CharSequence, cursor: Int): Int { + if (cursor == 0) { + return cursor + } + var i = cursor + var character = text[i - 1] + + // go up to first illegal character or character we're looking for (@, # or :) + while(i > 0 && !(character == '@' || character == '#' || character == ':')) { + if(!isMentionOrHashtagAllowedCharacter(character)) { + return cursor + } + + i-- + character = if (i == 0) ' ' else text[i - 1] + } + + // maybe caught domain name? try search username + if(i > 2 && character == '@') { + var j = i - 1 + var character2 = text[i - 2] + + // again go up to first illegal character or tag "@" + while(j > 0 && character2 != '@') { + if(!isMentionOrHashtagAllowedCharacter(character2)) { + break + } + + j-- + character2 = if (j == 0) ' ' else text[j - 1] + } + + // found mention symbol, override cursor + if(character2 == '@') { + i = j + character = character2 + } + } + + // Log.d("Tokenizer", "Stopped search at ${character} ${text.substring(i)}") + + if (i < 1 + || (character != '@' && character != '#' && character != ':') + || i > 1 && !Character.isWhitespace(text[i - 2])) { + return cursor + } + return i - 1 + } + + override fun findTokenEnd(text: CharSequence, cursor: Int): Int { + var i = cursor + val length = text.length + while (i < length) { + if (text[i] == ' ') { + return i + } else { + i++ + } + } + return length + } + + override fun terminateToken(text: CharSequence): CharSequence { + var i = text.length + while (i > 0 && text[i - 1] == ' ') { + i-- + } + return if (i > 0 && text[i - 1] == ' ') { + text + } else if (text is Spanned) { + val s = SpannableString(text.toString() + " ") + TextUtils.copySpansFrom(text, 0, text.length, Object::class.java, s, 0) + s + } else { + text.toString() + " " + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt new file mode 100644 index 0000000..fc84d6f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -0,0 +1,159 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +@file:JvmName("CustomEmojiHelper") +package com.keylesspalace.tusky.util + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.* +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ReplacementSpan +import android.view.View + +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.keylesspalace.tusky.entity.Emoji + +import java.lang.ref.WeakReference +import java.util.regex.Pattern +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.settings.PrefKeys + +/** + * replaces emoji shortcodes in a text with EmojiSpans + * @param text the text containing custom emojis + * @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances) + * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) + * @return the text with the shortcodes replaced by EmojiSpans +*/ +fun CharSequence.emojify(emojis: List?, view: View, forceSmallEmoji: Boolean) : CharSequence { + if(emojis.isNullOrEmpty()) + return this + + val builder = SpannableString.valueOf(this) + val pm = PreferenceManager.getDefaultSharedPreferences(view.context) + val smallEmojis = forceSmallEmoji || !pm.getBoolean(PrefKeys.BIG_EMOJIS, true) + val animate = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + + emojis.forEach { (shortcode, url) -> + val matcher = Pattern.compile(":$shortcode:", Pattern.LITERAL) + .matcher(this) + + while(matcher.find()) { + val span = if(smallEmojis) { + SmallEmojiSpan(WeakReference(view)) + } else { + EmojiSpan(WeakReference(view)) + } + + builder.setSpan(span, matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + Glide.with(view) + .asDrawable() + .load(url) + .into(span.getTarget(animate)) + } + } + return builder +} + +fun CharSequence.emojify(emojis: List?, view: View) : CharSequence { + return this.emojify(emojis, view, false) +} + +open class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() { + var imageDrawable: Drawable? = null + + override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?) : Int { + if (fm != null) { + /* update FontMetricsInt or otherwise span does not get drawn when + * it covers the whole text */ + val metrics = paint.fontMetricsInt + fm.top = (metrics.top * 1.3f).toInt() + fm.ascent = (metrics.ascent * 1.3f).toInt() + fm.descent = (metrics.descent * 2.0f).toInt() + fm.bottom = (metrics.bottom * 3.5f).toInt() + } + + return (paint.textSize * 2.0).toInt() + } + + override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { + imageDrawable?.let { drawable -> + canvas.save() + + val emojiSize = getSize(paint, text, start, end, null) + drawable.setBounds(0, 0, emojiSize, emojiSize) + + var transY = bottom - drawable.bounds.bottom + transY -= paint.fontMetricsInt.descent / 2 + + canvas.translate(x, transY.toFloat()) + drawable.draw(canvas) + canvas.restore() + } + } + + fun getTarget(animate : Boolean): Target { + return object : CustomTarget() { + override fun onResourceReady(resource: Drawable, transition: Transition?) { + viewWeakReference.get()?.let { view -> + if(animate && resource is Animatable) { + val callback = resource.callback + + resource.callback = object: Drawable.Callback { + override fun unscheduleDrawable(p0: Drawable, p1: Runnable) { + callback?.unscheduleDrawable(p0, p1) + } + override fun scheduleDrawable(p0: Drawable, p1: Runnable, p2: Long) { + callback?.scheduleDrawable(p0, p1, p2) + } + override fun invalidateDrawable(p0: Drawable) { + callback?.invalidateDrawable(p0) + view.invalidate() + } + } + resource.start() + } + + imageDrawable = resource + view.invalidate() + } + } + + override fun onLoadCleared(placeholder: Drawable?) {} + } + } +} + +class SmallEmojiSpan(viewWeakReference: WeakReference) + : EmojiSpan(viewWeakReference) { + override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + if (fm != null) { + /* update FontMetricsInt or otherwise span does not get drawn when + * it covers the whole text */ + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + fm.bottom = metrics.bottom + } + + return paint.textSize.toInt() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt new file mode 100644 index 0000000..bda2061 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt @@ -0,0 +1,28 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter + +abstract class CustomFragmentStateAdapter( + private val activity: FragmentActivity +): FragmentStateAdapter(activity) { + + fun getFragment(position: Int): Fragment? + = activity.supportFragmentManager.findFragmentByTag("f" + getItemId(position)) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomURLSpan.java b/app/src/main/java/com/keylesspalace/tusky/util/CustomURLSpan.java new file mode 100644 index 0000000..e772162 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomURLSpan.java @@ -0,0 +1,41 @@ +package com.keylesspalace.tusky.util; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextPaint; +import android.text.style.URLSpan; +import android.view.View; + +public class CustomURLSpan extends URLSpan { + public CustomURLSpan(String url) { + super(url); + } + + private CustomURLSpan(Parcel src) { + super(src); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public CustomURLSpan createFromParcel(Parcel source) { + return new CustomURLSpan(source); + } + + @Override + public CustomURLSpan[] newArray(int size) { + return new CustomURLSpan[size]; + } + + }; + + @Override + public void onClick(View view) { + LinkHelper.openLink(getURL(), view.getContext()); + } + + @Override public void updateDrawState(TextPaint ds) { + super.updateDrawState(ds); + ds.setUnderlineText(false); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt new file mode 100644 index 0000000..f0955cf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt @@ -0,0 +1,47 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +/** + * Created by charlag on 05/11/17. + * + * Class to represent sum type/tagged union/variant/ADT e.t.c. + * It is either Left or Right. + */ +sealed class Either { + data class Left(val value: L) : Either() + data class Right(val value: R) : Either() + + fun isRight() = this is Right + + fun isLeft() = this is Left + + fun asLeftOrNull() = (this as? Left)?.value + + fun asRightOrNull() = (this as? Right)?.value + + fun asLeft(): L = (this as Left).value + + fun asRight(): R = (this as Right).value + + inline fun map(crossinline mapper: (R) -> N): Either { + return if (this.isLeft()) { + Left(this.asLeft()) + } else { + Right(mapper(this.asRight())) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt new file mode 100644 index 0000000..68529c8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt @@ -0,0 +1,355 @@ +package com.keylesspalace.tusky.util + +import android.content.Context +import android.util.Log +import android.util.Pair +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.emoji.text.EmojiCompat +import androidx.emoji.bundled.BundledEmojiCompatConfig +import com.keylesspalace.tusky.R +import de.c1710.filemojicompat.FileEmojiCompatConfig +import io.reactivex.Observable +import io.reactivex.ObservableEmitter +import io.reactivex.schedulers.Schedulers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.internal.toLongOrDefault +import okio.Source +import okio.buffer +import okio.sink +import java.io.EOFException +import java.io.File +import java.io.FilenameFilter +import java.io.IOException +import kotlin.math.max + +/** + * This class bundles information about an emoji font as well as many convenient actions. + */ +class EmojiCompatFont( + val name: String, + private val display: String, + @StringRes val caption: Int, + @DrawableRes val img: Int, + val url: String, + // The version is stored as a String in the x.xx.xx format (to be able to compare versions) + val version: String) { + + private val versionCode = getVersionCode(version) + + // A list of all available font files and whether they are older than the current version or not + // They are ordered by their version codes in ascending order + private var existingFontFileCache: List>>? = null + + val id: Int + get() = FONTS.indexOf(this) + + fun getDisplay(context: Context): String { + return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default) + } + + /** + * This method will return the actual font file (regardless of its existence) for + * the current version (not necessarily the latest!). + * + * @return The font (TTF) file or null if called on SYSTEM_FONT + */ + private fun getFontFile(context: Context): File? { + return if (this !== SYSTEM_DEFAULT) { + val directory = File(context.getExternalFilesDir(null), DIRECTORY) + File(directory, "$name$version.ttf") + } else { + null + } + } + + fun getConfig(context: Context): EmojiCompat.Config { + if(this === SYSTEM_DEFAULT) + return BundledEmojiCompatConfig(context); + return FileEmojiCompatConfig(context, getLatestFontFile(context)) + } + + fun isDownloaded(context: Context): Boolean { + return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context) + } + + /** + * Checks whether there is already a font version that satisfies the current version, i.e. it + * has a higher or equal version code. + * + * @param context The Context + * @return Whether there is a font file with a higher or equal version code to the current + */ + private fun fontFileExists(context: Context): Boolean { + val existingFontFiles = getExistingFontFiles(context) + return if (existingFontFiles.isNotEmpty()) { + compareVersions(existingFontFiles.last().second, versionCode) >= 0 + } else { + false + } + } + + /** + * Deletes any older version of a font + * + * @param context The current Context + */ + private fun deleteOldVersions(context: Context) { + val existingFontFiles = getExistingFontFiles(context) + Log.d(TAG, "deleting old versions...") + Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size)) + for (fileExists in existingFontFiles) { + if (compareVersions(fileExists.second, versionCode) < 0) { + val file = fileExists.first + // Uses side effects! + Log.d(TAG, String.format("Deleted %s successfully: %s", file.absolutePath, + file.delete())) + } + } + } + + /** + * Loads all font files that are inside the files directory into an ArrayList with the information + * on whether they are older than the currently available version or not. + * + * @param context The Context + */ + private fun getExistingFontFiles(context: Context): List>> { + // Only load it once + existingFontFileCache?.let { + return it + } + // If we call this on the system default font, just return nothing... + if (this === SYSTEM_DEFAULT) { + existingFontFileCache = emptyList() + return emptyList() + } + + val directory = File(context.getExternalFilesDir(null), DIRECTORY) + // It will search for old versions using a regex that matches the font's name plus + // (if present) a version code. No version code will be regarded as version 0. + val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern() + val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") } + val foundFontFiles = directory.listFiles(ttfFilter).orEmpty() + Log.d(TAG, String.format("loadExistingFontFiles: %d other font files found", + foundFontFiles.size)) + + return foundFontFiles.map { file -> + val matcher = fontRegex.matcher(file.name) + val versionCode = if (matcher.matches()) { + val version = matcher.group(1) + getVersionCode(version) + } else { + listOf(0) + } + Pair(file, versionCode) + }.sortedWith( + Comparator>> { a, b -> compareVersions(a.second, b.second) } + ).also { + existingFontFileCache = it + } + } + + /** + * Returns the current or latest version of this font file (if there is any) + * + * @param context The Context + * @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font. + */ + private fun getLatestFontFile(context: Context): File? { + val current = getFontFile(context) + if (current != null && current.exists()) return current + val existingFontFiles = getExistingFontFiles(context) + return existingFontFiles.firstOrNull()?.first + } + + private fun getVersionCode(version: String?): List { + if (version == null) return listOf(0) + return version.split(".").map { + it.toIntOrNull() ?: 0 + } + } + + fun downloadFontFile(context: Context, + okHttpClient: OkHttpClient): Observable { + return Observable.create { emitter: ObservableEmitter -> + // It is possible (and very likely) that the file does not exist yet + val downloadFile = getFontFile(context)!! + if (!downloadFile.exists()) { + downloadFile.parentFile?.mkdirs() + downloadFile.createNewFile() + } + val request = Request.Builder().url(url) + .build() + + val sink = downloadFile.sink().buffer() + var source: Source? = null + try { + // Download! + val response = okHttpClient.newCall(request).execute() + + val responseBody = response.body + if (response.isSuccessful && responseBody != null) { + val size = response.length() + var progress = 0f + source = responseBody.source() + try { + while (!emitter.isDisposed) { + sink.write(source, CHUNK_SIZE) + progress += CHUNK_SIZE.toFloat() + if(size > 0) { + emitter.onNext(progress / size) + } else { + emitter.onNext(-1f) + } + } + } catch (ex: EOFException) { + /* + This means we've finished downloading the file since sink.write + will throw an EOFException when the file to be read is empty. + */ + } + } else { + Log.e(TAG, "Downloading $url failed. Status code: ${response.code}") + emitter.tryOnError(Exception()) + } + + } catch (ex: IOException) { + Log.e(TAG, "Downloading $url failed.", ex) + downloadFile.deleteIfExists() + emitter.tryOnError(ex) + } finally { + source?.close() + sink.close() + if (emitter.isDisposed) { + downloadFile.deleteIfExists() + } else { + deleteOldVersions(context) + emitter.onComplete() + } + } + + } + .subscribeOn(Schedulers.io()) + + } + + /** + * Deletes the downloaded file, if it exists. Should be called when a download gets cancelled. + */ + fun deleteDownloadedFile(context: Context) { + getFontFile(context)?.deleteIfExists() + } + + override fun toString(): String { + return display + } + + companion object { + private const val TAG = "EmojiCompatFont" + + /** + * This String represents the sub-directory the fonts are stored in. + */ + private const val DIRECTORY = "emoji" + + private const val CHUNK_SIZE = 4096L + + // The system font gets some special behavior... + private val SYSTEM_DEFAULT = EmojiCompatFont("system-default", + "System Default", + R.string.caption_systememoji, + R.drawable.ic_emoji_34dp, + "", + "0") + private val BLOBMOJI = EmojiCompatFont("Blobmoji", + "Blobmoji", + R.string.caption_blobmoji, + R.drawable.ic_blobmoji, + "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", + "12.0.0" + ) + private val TWEMOJI = EmojiCompatFont("Twemoji", + "Twemoji", + R.string.caption_twemoji, + R.drawable.ic_twemoji, + "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", + "12.0.0" + ) + private val NOTOEMOJI = EmojiCompatFont("NotoEmoji", + "Noto Emoji", + R.string.caption_notoemoji, + R.drawable.ic_notoemoji, + "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", + "11.0.0" + ) + + /** + * This array stores all available EmojiCompat fonts. + * References to them can simply be saved by saving their indices + */ + val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI) + + /** + * Returns the Emoji font associated with this ID + * + * @param id the ID of this font + * @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range. + */ + fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT } + + /** + * Compares two version codes to each other + * + * @param versionA The first version + * @param versionB The second version + * @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise + */ + @VisibleForTesting + fun compareVersions(versionA: List, versionB: List): Int { + val len = max(versionB.size, versionA.size) + for (i in 0 until len) { + + val vA = versionA.getOrElse(i) { 0 } + val vB = versionB.getOrElse(i) { 0 } + + // It needs to be decided on the next level + if (vA == vB) continue + // Okay, is version B newer or version A? + return vA.compareTo(vB) + } + + // The versions are equal + return 0 + } + + /** + * This method is needed because when transparent compression is used OkHttp reports + * [ResponseBody.contentLength] as -1. We try to get the header which server sent + * us manually here. + * + * @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259) + */ + private fun Response.length(): Long { + networkResponse?.let { + val header = it.header("Content-Length") ?: return -1 + return header.toLongOrDefault(-1) + } + + // In case it's a fully cached response + return body?.contentLength() ?: -1 + } + + private fun File.deleteIfExists() { + if(exists() && !delete()) { + Log.e(TAG, "Could not delete file $this") + } + } + + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Emojis.java b/app/src/main/java/com/keylesspalace/tusky/util/Emojis.java new file mode 100644 index 0000000..61de258 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Emojis.java @@ -0,0 +1,1084 @@ +package com.keylesspalace.tusky.util; + +// AUTOGENERATED +public class Emojis +{ + public static final String[][] EMOJIS = new String[][] { +{ // # group: Smileys & Emotion +"😀", // 1F600 ; fully-qualified # 😀 grinning face +"😃", // 1F603 ; fully-qualified # 😃 grinning face with big eyes +"😄", // 1F604 ; fully-qualified # 😄 grinning face with smiling eyes +"😁", // 1F601 ; fully-qualified # 😁 beaming face with smiling eyes +"😆", // 1F606 ; fully-qualified # 😆 grinning squinting face +"😅", // 1F605 ; fully-qualified # 😅 grinning face with sweat +"🤣", // 1F923 ; fully-qualified # 🤣 rolling on the floor laughing +"😂", // 1F602 ; fully-qualified # 😂 face with tears of joy +"🙂", // 1F642 ; fully-qualified # 🙂 slightly smiling face +"🙃", // 1F643 ; fully-qualified # 🙃 upside-down face +"😉", // 1F609 ; fully-qualified # 😉 winking face +"😊", // 1F60A ; fully-qualified # 😊 smiling face with smiling eyes +"😇", // 1F607 ; fully-qualified # 😇 smiling face with halo +"🥰", // 1F970 ; fully-qualified # 🥰 smiling face with hearts +"😍", // 1F60D ; fully-qualified # 😍 smiling face with heart-eyes +"🤩", // 1F929 ; fully-qualified # 🤩 star-struck +"😘", // 1F618 ; fully-qualified # 😘 face blowing a kiss +"😗", // 1F617 ; fully-qualified # 😗 kissing face +"😚", // 1F61A ; fully-qualified # 😚 kissing face with closed eyes +"😙", // 1F619 ; fully-qualified # 😙 kissing face with smiling eyes +"😋", // 1F60B ; fully-qualified # 😋 face savoring food +"😛", // 1F61B ; fully-qualified # 😛 face with tongue +"😜", // 1F61C ; fully-qualified # 😜 winking face with tongue +"🤪", // 1F92A ; fully-qualified # 🤪 zany face +"😝", // 1F61D ; fully-qualified # 😝 squinting face with tongue +"🤑", // 1F911 ; fully-qualified # 🤑 money-mouth face +"🤗", // 1F917 ; fully-qualified # 🤗 hugging face +"🤭", // 1F92D ; fully-qualified # 🤭 face with hand over mouth +"🤫", // 1F92B ; fully-qualified # 🤫 shushing face +"🤔", // 1F914 ; fully-qualified # 🤔 thinking face +"🤐", // 1F910 ; fully-qualified # 🤐 zipper-mouth face +"🤨", // 1F928 ; fully-qualified # 🤨 face with raised eyebrow +"😐", // 1F610 ; fully-qualified # 😐 neutral face +"😑", // 1F611 ; fully-qualified # 😑 expressionless face +"😶", // 1F636 ; fully-qualified # 😶 face without mouth +"😏", // 1F60F ; fully-qualified # 😏 smirking face +"😒", // 1F612 ; fully-qualified # 😒 unamused face +"🙄", // 1F644 ; fully-qualified # 🙄 face with rolling eyes +"😬", // 1F62C ; fully-qualified # 😬 grimacing face +"🤥", // 1F925 ; fully-qualified # 🤥 lying face +"😌", // 1F60C ; fully-qualified # 😌 relieved face +"😔", // 1F614 ; fully-qualified # 😔 pensive face +"😪", // 1F62A ; fully-qualified # 😪 sleepy face +"🤤", // 1F924 ; fully-qualified # 🤤 drooling face +"😴", // 1F634 ; fully-qualified # 😴 sleeping face +"😷", // 1F637 ; fully-qualified # 😷 face with medical mask +"🤒", // 1F912 ; fully-qualified # 🤒 face with thermometer +"🤕", // 1F915 ; fully-qualified # 🤕 face with head-bandage +"🤢", // 1F922 ; fully-qualified # 🤢 nauseated face +"🤮", // 1F92E ; fully-qualified # 🤮 face vomiting +"🤧", // 1F927 ; fully-qualified # 🤧 sneezing face +"🥵", // 1F975 ; fully-qualified # 🥵 hot face +"🥶", // 1F976 ; fully-qualified # 🥶 cold face +"🥴", // 1F974 ; fully-qualified # 🥴 woozy face +"😵", // 1F635 ; fully-qualified # 😵 dizzy face +"🤯", // 1F92F ; fully-qualified # 🤯 exploding head +"🤠", // 1F920 ; fully-qualified # 🤠 cowboy hat face +"🥳", // 1F973 ; fully-qualified # 🥳 partying face +"😎", // 1F60E ; fully-qualified # 😎 smiling face with sunglasses +"🤓", // 1F913 ; fully-qualified # 🤓 nerd face +"🧐", // 1F9D0 ; fully-qualified # 🧐 face with monocle +"😕", // 1F615 ; fully-qualified # 😕 confused face +"😟", // 1F61F ; fully-qualified # 😟 worried face +"🙁", // 1F641 ; fully-qualified # 🙁 slightly frowning face +"😮", // 1F62E ; fully-qualified # 😮 face with open mouth +"😯", // 1F62F ; fully-qualified # 😯 hushed face +"😲", // 1F632 ; fully-qualified # 😲 astonished face +"😳", // 1F633 ; fully-qualified # 😳 flushed face +"🥺", // 1F97A ; fully-qualified # 🥺 pleading face +"😦", // 1F626 ; fully-qualified # 😦 frowning face with open mouth +"😧", // 1F627 ; fully-qualified # 😧 anguished face +"😨", // 1F628 ; fully-qualified # 😨 fearful face +"😰", // 1F630 ; fully-qualified # 😰 anxious face with sweat +"😥", // 1F625 ; fully-qualified # 😥 sad but relieved face +"😢", // 1F622 ; fully-qualified # 😢 crying face +"😭", // 1F62D ; fully-qualified # 😭 loudly crying face +"😱", // 1F631 ; fully-qualified # 😱 face screaming in fear +"😖", // 1F616 ; fully-qualified # 😖 confounded face +"😣", // 1F623 ; fully-qualified # 😣 persevering face +"😞", // 1F61E ; fully-qualified # 😞 disappointed face +"😓", // 1F613 ; fully-qualified # 😓 downcast face with sweat +"😩", // 1F629 ; fully-qualified # 😩 weary face +"😫", // 1F62B ; fully-qualified # 😫 tired face +"🥱", // 1F971 ; fully-qualified # 🥱 yawning face +"😤", // 1F624 ; fully-qualified # 😤 face with steam from nose +"😡", // 1F621 ; fully-qualified # 😡 pouting face +"😠", // 1F620 ; fully-qualified # 😠 angry face +"🤬", // 1F92C ; fully-qualified # 🤬 face with symbols on mouth +"😈", // 1F608 ; fully-qualified # 😈 smiling face with horns +"👿", // 1F47F ; fully-qualified # 👿 angry face with horns +"💀", // 1F480 ; fully-qualified # 💀 skull +"💩", // 1F4A9 ; fully-qualified # 💩 pile of poo +"🤡", // 1F921 ; fully-qualified # 🤡 clown face +"👹", // 1F479 ; fully-qualified # 👹 ogre +"👺", // 1F47A ; fully-qualified # 👺 goblin +"👻", // 1F47B ; fully-qualified # 👻 ghost +"👽", // 1F47D ; fully-qualified # 👽 alien +"👾", // 1F47E ; fully-qualified # 👾 alien monster +"🤖", // 1F916 ; fully-qualified # 🤖 robot +"😺", // 1F63A ; fully-qualified # 😺 grinning cat +"😸", // 1F638 ; fully-qualified # 😸 grinning cat with smiling eyes +"😹", // 1F639 ; fully-qualified # 😹 cat with tears of joy +"😻", // 1F63B ; fully-qualified # 😻 smiling cat with heart-eyes +"😼", // 1F63C ; fully-qualified # 😼 cat with wry smile +"😽", // 1F63D ; fully-qualified # 😽 kissing cat +"🙀", // 1F640 ; fully-qualified # 🙀 weary cat +"😿", // 1F63F ; fully-qualified # 😿 crying cat +"😾", // 1F63E ; fully-qualified # 😾 pouting cat +"🙈", // 1F648 ; fully-qualified # 🙈 see-no-evil monkey +"🙉", // 1F649 ; fully-qualified # 🙉 hear-no-evil monkey +"🙊", // 1F64A ; fully-qualified # 🙊 speak-no-evil monkey +"💋", // 1F48B ; fully-qualified # 💋 kiss mark +"💌", // 1F48C ; fully-qualified # 💌 love letter +"💘", // 1F498 ; fully-qualified # 💘 heart with arrow +"💝", // 1F49D ; fully-qualified # 💝 heart with ribbon +"💖", // 1F496 ; fully-qualified # 💖 sparkling heart +"💗", // 1F497 ; fully-qualified # 💗 growing heart +"💓", // 1F493 ; fully-qualified # 💓 beating heart +"💞", // 1F49E ; fully-qualified # 💞 revolving hearts +"💕", // 1F495 ; fully-qualified # 💕 two hearts +"💟", // 1F49F ; fully-qualified # 💟 heart decoration +"💔", // 1F494 ; fully-qualified # 💔 broken heart +"🧡", // 1F9E1 ; fully-qualified # 🧡 orange heart +"💛", // 1F49B ; fully-qualified # 💛 yellow heart +"💚", // 1F49A ; fully-qualified # 💚 green heart +"💙", // 1F499 ; fully-qualified # 💙 blue heart +"💜", // 1F49C ; fully-qualified # 💜 purple heart +"🤎", // 1F90E ; fully-qualified # 🤎 brown heart +"🖤", // 1F5A4 ; fully-qualified # 🖤 black heart +"🤍", // 1F90D ; fully-qualified # 🤍 white heart +"💯", // 1F4AF ; fully-qualified # 💯 hundred points +"💢", // 1F4A2 ; fully-qualified # 💢 anger symbol +"💥", // 1F4A5 ; fully-qualified # 💥 collision +"💫", // 1F4AB ; fully-qualified # 💫 dizzy +"💦", // 1F4A6 ; fully-qualified # 💦 sweat droplets +"💨", // 1F4A8 ; fully-qualified # 💨 dashing away +"💣", // 1F4A3 ; fully-qualified # 💣 bomb +"💬", // 1F4AC ; fully-qualified # 💬 speech balloon +"💭", // 1F4AD ; fully-qualified # 💭 thought balloon +"💤", // 1F4A4 ; fully-qualified # 💤 zzz +}, +{ // # group: People & Body +"👋", // 1F44B ; fully-qualified # 👋 waving hand +"🤚", // 1F91A ; fully-qualified # 🤚 raised back of hand +"✋", // 270B ; fully-qualified # ✋ raised hand +"🖖", // 1F596 ; fully-qualified # 🖖 vulcan salute +"👌", // 1F44C ; fully-qualified # 👌 OK hand +"🤏", // 1F90F ; fully-qualified # 🤏 pinching hand +"🤞", // 1F91E ; fully-qualified # 🤞 crossed fingers +"🤟", // 1F91F ; fully-qualified # 🤟 love-you gesture +"🤘", // 1F918 ; fully-qualified # 🤘 sign of the horns +"🤙", // 1F919 ; fully-qualified # 🤙 call me hand +"👈", // 1F448 ; fully-qualified # 👈 backhand index pointing left +"👉", // 1F449 ; fully-qualified # 👉 backhand index pointing right +"👆", // 1F446 ; fully-qualified # 👆 backhand index pointing up +"🖕", // 1F595 ; fully-qualified # 🖕 middle finger +"👇", // 1F447 ; fully-qualified # 👇 backhand index pointing down +"👍", // 1F44D ; fully-qualified # 👍 thumbs up +"👎", // 1F44E ; fully-qualified # 👎 thumbs down +"✊", // 270A ; fully-qualified # ✊ raised fist +"👊", // 1F44A ; fully-qualified # 👊 oncoming fist +"🤛", // 1F91B ; fully-qualified # 🤛 left-facing fist +"🤜", // 1F91C ; fully-qualified # 🤜 right-facing fist +"👏", // 1F44F ; fully-qualified # 👏 clapping hands +"🙌", // 1F64C ; fully-qualified # 🙌 raising hands +"👐", // 1F450 ; fully-qualified # 👐 open hands +"🤲", // 1F932 ; fully-qualified # 🤲 palms up together +"🤝", // 1F91D ; fully-qualified # 🤝 handshake +"🙏", // 1F64F ; fully-qualified # 🙏 folded hands +"💅", // 1F485 ; fully-qualified # 💅 nail polish +"🤳", // 1F933 ; fully-qualified # 🤳 selfie +"💪", // 1F4AA ; fully-qualified # 💪 flexed biceps +"🦾", // 1F9BE ; fully-qualified # 🦾 mechanical arm +"🦿", // 1F9BF ; fully-qualified # 🦿 mechanical leg +"🦵", // 1F9B5 ; fully-qualified # 🦵 leg +"🦶", // 1F9B6 ; fully-qualified # 🦶 foot +"👂", // 1F442 ; fully-qualified # 👂 ear +"🦻", // 1F9BB ; fully-qualified # 🦻 ear with hearing aid +"👃", // 1F443 ; fully-qualified # 👃 nose +"🧠", // 1F9E0 ; fully-qualified # 🧠 brain +"🦷", // 1F9B7 ; fully-qualified # 🦷 tooth +"🦴", // 1F9B4 ; fully-qualified # 🦴 bone +"👀", // 1F440 ; fully-qualified # 👀 eyes +"👅", // 1F445 ; fully-qualified # 👅 tongue +"👄", // 1F444 ; fully-qualified # 👄 mouth +"👶", // 1F476 ; fully-qualified # 👶 baby +"🧒", // 1F9D2 ; fully-qualified # 🧒 child +"👦", // 1F466 ; fully-qualified # 👦 boy +"👧", // 1F467 ; fully-qualified # 👧 girl +"🧑", // 1F9D1 ; fully-qualified # 🧑 person +"👱", // 1F471 ; fully-qualified # 👱 person: blond hair +"👨", // 1F468 ; fully-qualified # 👨 man +"🧔", // 1F9D4 ; fully-qualified # 🧔 man: beard +"👩", // 1F469 ; fully-qualified # 👩 woman +"🧓", // 1F9D3 ; fully-qualified # 🧓 older person +"👴", // 1F474 ; fully-qualified # 👴 old man +"👵", // 1F475 ; fully-qualified # 👵 old woman +"🙍", // 1F64D ; fully-qualified # 🙍 person frowning +"🙎", // 1F64E ; fully-qualified # 🙎 person pouting +"🙅", // 1F645 ; fully-qualified # 🙅 person gesturing NO +"🙆", // 1F646 ; fully-qualified # 🙆 person gesturing OK +"💁", // 1F481 ; fully-qualified # 💁 person tipping hand +"🙋", // 1F64B ; fully-qualified # 🙋 person raising hand +"🧏", // 1F9CF ; fully-qualified # 🧏 deaf person +"🙇", // 1F647 ; fully-qualified # 🙇 person bowing +"🤦", // 1F926 ; fully-qualified # 🤦 person facepalming +"🤷", // 1F937 ; fully-qualified # 🤷 person shrugging +"👮", // 1F46E ; fully-qualified # 👮 police officer +"💂", // 1F482 ; fully-qualified # 💂 guard +"👷", // 1F477 ; fully-qualified # 👷 construction worker +"🤴", // 1F934 ; fully-qualified # 🤴 prince +"👸", // 1F478 ; fully-qualified # 👸 princess +"👳", // 1F473 ; fully-qualified # 👳 person wearing turban +"👲", // 1F472 ; fully-qualified # 👲 man with Chinese cap +"🧕", // 1F9D5 ; fully-qualified # 🧕 woman with headscarf +"🤵", // 1F935 ; fully-qualified # 🤵 man in tuxedo +"👰", // 1F470 ; fully-qualified # 👰 bride with veil +"🤰", // 1F930 ; fully-qualified # 🤰 pregnant woman +"🤱", // 1F931 ; fully-qualified # 🤱 breast-feeding +"👼", // 1F47C ; fully-qualified # 👼 baby angel +"🎅", // 1F385 ; fully-qualified # 🎅 Santa Claus +"🤶", // 1F936 ; fully-qualified # 🤶 Mrs. Claus +"🦸", // 1F9B8 ; fully-qualified # 🦸 superhero +"🦹", // 1F9B9 ; fully-qualified # 🦹 supervillain +"🧙", // 1F9D9 ; fully-qualified # 🧙 mage +"🧚", // 1F9DA ; fully-qualified # 🧚 fairy +"🧛", // 1F9DB ; fully-qualified # 🧛 vampire +"🧜", // 1F9DC ; fully-qualified # 🧜 merperson +"🧝", // 1F9DD ; fully-qualified # 🧝 elf +"🧞", // 1F9DE ; fully-qualified # 🧞 genie +"🧟", // 1F9DF ; fully-qualified # 🧟 zombie +"💆", // 1F486 ; fully-qualified # 💆 person getting massage +"💇", // 1F487 ; fully-qualified # 💇 person getting haircut +"🚶", // 1F6B6 ; fully-qualified # 🚶 person walking +"🧍", // 1F9CD ; fully-qualified # 🧍 person standing +"🧎", // 1F9CE ; fully-qualified # 🧎 person kneeling +"🏃", // 1F3C3 ; fully-qualified # 🏃 person running +"💃", // 1F483 ; fully-qualified # 💃 woman dancing +"🕺", // 1F57A ; fully-qualified # 🕺 man dancing +"👯", // 1F46F ; fully-qualified # 👯 people with bunny ears +"🧖", // 1F9D6 ; fully-qualified # 🧖 person in steamy room +"🧗", // 1F9D7 ; fully-qualified # 🧗 person climbing +"🤺", // 1F93A ; fully-qualified # 🤺 person fencing +"🏇", // 1F3C7 ; fully-qualified # 🏇 horse racing +"🏂", // 1F3C2 ; fully-qualified # 🏂 snowboarder +"🏄", // 1F3C4 ; fully-qualified # 🏄 person surfing +"🚣", // 1F6A3 ; fully-qualified # 🚣 person rowing boat +"🏊", // 1F3CA ; fully-qualified # 🏊 person swimming +"🚴", // 1F6B4 ; fully-qualified # 🚴 person biking +"🚵", // 1F6B5 ; fully-qualified # 🚵 person mountain biking +"🤸", // 1F938 ; fully-qualified # 🤸 person cartwheeling +"🤼", // 1F93C ; fully-qualified # 🤼 people wrestling +"🤽", // 1F93D ; fully-qualified # 🤽 person playing water polo +"🤾", // 1F93E ; fully-qualified # 🤾 person playing handball +"🤹", // 1F939 ; fully-qualified # 🤹 person juggling +"🧘", // 1F9D8 ; fully-qualified # 🧘 person in lotus position +"🛀", // 1F6C0 ; fully-qualified # 🛀 person taking bath +"🛌", // 1F6CC ; fully-qualified # 🛌 person in bed +"👭", // 1F46D ; fully-qualified # 👭 women holding hands +"👫", // 1F46B ; fully-qualified # 👫 woman and man holding hands +"👬", // 1F46C ; fully-qualified # 👬 men holding hands +"💏", // 1F48F ; fully-qualified # 💏 kiss +"💑", // 1F491 ; fully-qualified # 💑 couple with heart +"👪", // 1F46A ; fully-qualified # 👪 family +"👤", // 1F464 ; fully-qualified # 👤 bust in silhouette +"👥", // 1F465 ; fully-qualified # 👥 busts in silhouette +"👣", // 1F463 ; fully-qualified # 👣 footprints +}, +{ // # group: Animals & Nature +"🐵", // 1F435 ; fully-qualified # 🐵 monkey face +"🐒", // 1F412 ; fully-qualified # 🐒 monkey +"🦍", // 1F98D ; fully-qualified # 🦍 gorilla +"🦧", // 1F9A7 ; fully-qualified # 🦧 orangutan +"🐶", // 1F436 ; fully-qualified # 🐶 dog face +"🐕", // 1F415 ; fully-qualified # 🐕 dog +"🦮", // 1F9AE ; fully-qualified # 🦮 guide dog +"🐩", // 1F429 ; fully-qualified # 🐩 poodle +"🐺", // 1F43A ; fully-qualified # 🐺 wolf +"🦊", // 1F98A ; fully-qualified # 🦊 fox +"🦝", // 1F99D ; fully-qualified # 🦝 raccoon +"🐱", // 1F431 ; fully-qualified # 🐱 cat face +"🐈", // 1F408 ; fully-qualified # 🐈 cat +"🦁", // 1F981 ; fully-qualified # 🦁 lion +"🐯", // 1F42F ; fully-qualified # 🐯 tiger face +"🐅", // 1F405 ; fully-qualified # 🐅 tiger +"🐆", // 1F406 ; fully-qualified # 🐆 leopard +"🐴", // 1F434 ; fully-qualified # 🐴 horse face +"🐎", // 1F40E ; fully-qualified # 🐎 horse +"🦄", // 1F984 ; fully-qualified # 🦄 unicorn +"🦓", // 1F993 ; fully-qualified # 🦓 zebra +"🦌", // 1F98C ; fully-qualified # 🦌 deer +"🐮", // 1F42E ; fully-qualified # 🐮 cow face +"🐂", // 1F402 ; fully-qualified # 🐂 ox +"🐃", // 1F403 ; fully-qualified # 🐃 water buffalo +"🐄", // 1F404 ; fully-qualified # 🐄 cow +"🐷", // 1F437 ; fully-qualified # 🐷 pig face +"🐖", // 1F416 ; fully-qualified # 🐖 pig +"🐗", // 1F417 ; fully-qualified # 🐗 boar +"🐽", // 1F43D ; fully-qualified # 🐽 pig nose +"🐏", // 1F40F ; fully-qualified # 🐏 ram +"🐑", // 1F411 ; fully-qualified # 🐑 ewe +"🐐", // 1F410 ; fully-qualified # 🐐 goat +"🐪", // 1F42A ; fully-qualified # 🐪 camel +"🐫", // 1F42B ; fully-qualified # 🐫 two-hump camel +"🦙", // 1F999 ; fully-qualified # 🦙 llama +"🦒", // 1F992 ; fully-qualified # 🦒 giraffe +"🐘", // 1F418 ; fully-qualified # 🐘 elephant +"🦏", // 1F98F ; fully-qualified # 🦏 rhinoceros +"🦛", // 1F99B ; fully-qualified # 🦛 hippopotamus +"🐭", // 1F42D ; fully-qualified # 🐭 mouse face +"🐁", // 1F401 ; fully-qualified # 🐁 mouse +"🐀", // 1F400 ; fully-qualified # 🐀 rat +"🐹", // 1F439 ; fully-qualified # 🐹 hamster +"🐰", // 1F430 ; fully-qualified # 🐰 rabbit face +"🐇", // 1F407 ; fully-qualified # 🐇 rabbit +"🦔", // 1F994 ; fully-qualified # 🦔 hedgehog +"🦇", // 1F987 ; fully-qualified # 🦇 bat +"🐻", // 1F43B ; fully-qualified # 🐻 bear +"🐨", // 1F428 ; fully-qualified # 🐨 koala +"🐼", // 1F43C ; fully-qualified # 🐼 panda +"🦥", // 1F9A5 ; fully-qualified # 🦥 sloth +"🦦", // 1F9A6 ; fully-qualified # 🦦 otter +"🦨", // 1F9A8 ; fully-qualified # 🦨 skunk +"🦘", // 1F998 ; fully-qualified # 🦘 kangaroo +"🦡", // 1F9A1 ; fully-qualified # 🦡 badger +"🐾", // 1F43E ; fully-qualified # 🐾 paw prints +"🦃", // 1F983 ; fully-qualified # 🦃 turkey +"🐔", // 1F414 ; fully-qualified # 🐔 chicken +"🐓", // 1F413 ; fully-qualified # 🐓 rooster +"🐣", // 1F423 ; fully-qualified # 🐣 hatching chick +"🐤", // 1F424 ; fully-qualified # 🐤 baby chick +"🐥", // 1F425 ; fully-qualified # 🐥 front-facing baby chick +"🐦", // 1F426 ; fully-qualified # 🐦 bird +"🐧", // 1F427 ; fully-qualified # 🐧 penguin +"🦅", // 1F985 ; fully-qualified # 🦅 eagle +"🦆", // 1F986 ; fully-qualified # 🦆 duck +"🦢", // 1F9A2 ; fully-qualified # 🦢 swan +"🦉", // 1F989 ; fully-qualified # 🦉 owl +"🦩", // 1F9A9 ; fully-qualified # 🦩 flamingo +"🦚", // 1F99A ; fully-qualified # 🦚 peacock +"🦜", // 1F99C ; fully-qualified # 🦜 parrot +"🐸", // 1F438 ; fully-qualified # 🐸 frog +"🐊", // 1F40A ; fully-qualified # 🐊 crocodile +"🐢", // 1F422 ; fully-qualified # 🐢 turtle +"🦎", // 1F98E ; fully-qualified # 🦎 lizard +"🐍", // 1F40D ; fully-qualified # 🐍 snake +"🐲", // 1F432 ; fully-qualified # 🐲 dragon face +"🐉", // 1F409 ; fully-qualified # 🐉 dragon +"🦕", // 1F995 ; fully-qualified # 🦕 sauropod +"🦖", // 1F996 ; fully-qualified # 🦖 T-Rex +"🐳", // 1F433 ; fully-qualified # 🐳 spouting whale +"🐋", // 1F40B ; fully-qualified # 🐋 whale +"🐬", // 1F42C ; fully-qualified # 🐬 dolphin +"🐟", // 1F41F ; fully-qualified # 🐟 fish +"🐠", // 1F420 ; fully-qualified # 🐠 tropical fish +"🐡", // 1F421 ; fully-qualified # 🐡 blowfish +"🦈", // 1F988 ; fully-qualified # 🦈 shark +"🐙", // 1F419 ; fully-qualified # 🐙 octopus +"🐚", // 1F41A ; fully-qualified # 🐚 spiral shell +"🐌", // 1F40C ; fully-qualified # 🐌 snail +"🦋", // 1F98B ; fully-qualified # 🦋 butterfly +"🐛", // 1F41B ; fully-qualified # 🐛 bug +"🐜", // 1F41C ; fully-qualified # 🐜 ant +"🐝", // 1F41D ; fully-qualified # 🐝 honeybee +"🐞", // 1F41E ; fully-qualified # 🐞 lady beetle +"🦗", // 1F997 ; fully-qualified # 🦗 cricket +"🦂", // 1F982 ; fully-qualified # 🦂 scorpion +"🦟", // 1F99F ; fully-qualified # 🦟 mosquito +"🦠", // 1F9A0 ; fully-qualified # 🦠 microbe +"💐", // 1F490 ; fully-qualified # 💐 bouquet +"🌸", // 1F338 ; fully-qualified # 🌸 cherry blossom +"💮", // 1F4AE ; fully-qualified # 💮 white flower +"🌹", // 1F339 ; fully-qualified # 🌹 rose +"🥀", // 1F940 ; fully-qualified # 🥀 wilted flower +"🌺", // 1F33A ; fully-qualified # 🌺 hibiscus +"🌻", // 1F33B ; fully-qualified # 🌻 sunflower +"🌼", // 1F33C ; fully-qualified # 🌼 blossom +"🌷", // 1F337 ; fully-qualified # 🌷 tulip +"🌱", // 1F331 ; fully-qualified # 🌱 seedling +"🌲", // 1F332 ; fully-qualified # 🌲 evergreen tree +"🌳", // 1F333 ; fully-qualified # 🌳 deciduous tree +"🌴", // 1F334 ; fully-qualified # 🌴 palm tree +"🌵", // 1F335 ; fully-qualified # 🌵 cactus +"🌾", // 1F33E ; fully-qualified # 🌾 sheaf of rice +"🌿", // 1F33F ; fully-qualified # 🌿 herb +"🍀", // 1F340 ; fully-qualified # 🍀 four leaf clover +"🍁", // 1F341 ; fully-qualified # 🍁 maple leaf +"🍂", // 1F342 ; fully-qualified # 🍂 fallen leaf +"🍃", // 1F343 ; fully-qualified # 🍃 leaf fluttering in wind +}, +{ // # group: Food & Drink +"🍇", // 1F347 ; fully-qualified # 🍇 grapes +"🍈", // 1F348 ; fully-qualified # 🍈 melon +"🍉", // 1F349 ; fully-qualified # 🍉 watermelon +"🍊", // 1F34A ; fully-qualified # 🍊 tangerine +"🍋", // 1F34B ; fully-qualified # 🍋 lemon +"🍌", // 1F34C ; fully-qualified # 🍌 banana +"🍍", // 1F34D ; fully-qualified # 🍍 pineapple +"🥭", // 1F96D ; fully-qualified # 🥭 mango +"🍎", // 1F34E ; fully-qualified # 🍎 red apple +"🍏", // 1F34F ; fully-qualified # 🍏 green apple +"🍐", // 1F350 ; fully-qualified # 🍐 pear +"🍑", // 1F351 ; fully-qualified # 🍑 peach +"🍒", // 1F352 ; fully-qualified # 🍒 cherries +"🍓", // 1F353 ; fully-qualified # 🍓 strawberry +"🥝", // 1F95D ; fully-qualified # 🥝 kiwi fruit +"🍅", // 1F345 ; fully-qualified # 🍅 tomato +"🥥", // 1F965 ; fully-qualified # 🥥 coconut +"🥑", // 1F951 ; fully-qualified # 🥑 avocado +"🍆", // 1F346 ; fully-qualified # 🍆 eggplant +"🥔", // 1F954 ; fully-qualified # 🥔 potato +"🥕", // 1F955 ; fully-qualified # 🥕 carrot +"🌽", // 1F33D ; fully-qualified # 🌽 ear of corn +"🥒", // 1F952 ; fully-qualified # 🥒 cucumber +"🥬", // 1F96C ; fully-qualified # 🥬 leafy green +"🥦", // 1F966 ; fully-qualified # 🥦 broccoli +"🧄", // 1F9C4 ; fully-qualified # 🧄 garlic +"🧅", // 1F9C5 ; fully-qualified # 🧅 onion +"🍄", // 1F344 ; fully-qualified # 🍄 mushroom +"🥜", // 1F95C ; fully-qualified # 🥜 peanuts +"🌰", // 1F330 ; fully-qualified # 🌰 chestnut +"🍞", // 1F35E ; fully-qualified # 🍞 bread +"🥐", // 1F950 ; fully-qualified # 🥐 croissant +"🥖", // 1F956 ; fully-qualified # 🥖 baguette bread +"🥨", // 1F968 ; fully-qualified # 🥨 pretzel +"🥯", // 1F96F ; fully-qualified # 🥯 bagel +"🥞", // 1F95E ; fully-qualified # 🥞 pancakes +"🧇", // 1F9C7 ; fully-qualified # 🧇 waffle +"🧀", // 1F9C0 ; fully-qualified # 🧀 cheese wedge +"🍖", // 1F356 ; fully-qualified # 🍖 meat on bone +"🍗", // 1F357 ; fully-qualified # 🍗 poultry leg +"🥩", // 1F969 ; fully-qualified # 🥩 cut of meat +"🥓", // 1F953 ; fully-qualified # 🥓 bacon +"🍔", // 1F354 ; fully-qualified # 🍔 hamburger +"🍟", // 1F35F ; fully-qualified # 🍟 french fries +"🍕", // 1F355 ; fully-qualified # 🍕 pizza +"🌭", // 1F32D ; fully-qualified # 🌭 hot dog +"🥪", // 1F96A ; fully-qualified # 🥪 sandwich +"🌮", // 1F32E ; fully-qualified # 🌮 taco +"🌯", // 1F32F ; fully-qualified # 🌯 burrito +"🥙", // 1F959 ; fully-qualified # 🥙 stuffed flatbread +"🧆", // 1F9C6 ; fully-qualified # 🧆 falafel +"🥚", // 1F95A ; fully-qualified # 🥚 egg +"🍳", // 1F373 ; fully-qualified # 🍳 cooking +"🥘", // 1F958 ; fully-qualified # 🥘 shallow pan of food +"🍲", // 1F372 ; fully-qualified # 🍲 pot of food +"🥣", // 1F963 ; fully-qualified # 🥣 bowl with spoon +"🥗", // 1F957 ; fully-qualified # 🥗 green salad +"🍿", // 1F37F ; fully-qualified # 🍿 popcorn +"🧈", // 1F9C8 ; fully-qualified # 🧈 butter +"🧂", // 1F9C2 ; fully-qualified # 🧂 salt +"🥫", // 1F96B ; fully-qualified # 🥫 canned food +"🍱", // 1F371 ; fully-qualified # 🍱 bento box +"🍘", // 1F358 ; fully-qualified # 🍘 rice cracker +"🍙", // 1F359 ; fully-qualified # 🍙 rice ball +"🍚", // 1F35A ; fully-qualified # 🍚 cooked rice +"🍛", // 1F35B ; fully-qualified # 🍛 curry rice +"🍜", // 1F35C ; fully-qualified # 🍜 steaming bowl +"🍝", // 1F35D ; fully-qualified # 🍝 spaghetti +"🍠", // 1F360 ; fully-qualified # 🍠 roasted sweet potato +"🍢", // 1F362 ; fully-qualified # 🍢 oden +"🍣", // 1F363 ; fully-qualified # 🍣 sushi +"🍤", // 1F364 ; fully-qualified # 🍤 fried shrimp +"🍥", // 1F365 ; fully-qualified # 🍥 fish cake with swirl +"🥮", // 1F96E ; fully-qualified # 🥮 moon cake +"🍡", // 1F361 ; fully-qualified # 🍡 dango +"🥟", // 1F95F ; fully-qualified # 🥟 dumpling +"🥠", // 1F960 ; fully-qualified # 🥠 fortune cookie +"🥡", // 1F961 ; fully-qualified # 🥡 takeout box +"🦀", // 1F980 ; fully-qualified # 🦀 crab +"🦞", // 1F99E ; fully-qualified # 🦞 lobster +"🦐", // 1F990 ; fully-qualified # 🦐 shrimp +"🦑", // 1F991 ; fully-qualified # 🦑 squid +"🦪", // 1F9AA ; fully-qualified # 🦪 oyster +"🍦", // 1F366 ; fully-qualified # 🍦 soft ice cream +"🍧", // 1F367 ; fully-qualified # 🍧 shaved ice +"🍨", // 1F368 ; fully-qualified # 🍨 ice cream +"🍩", // 1F369 ; fully-qualified # 🍩 doughnut +"🍪", // 1F36A ; fully-qualified # 🍪 cookie +"🎂", // 1F382 ; fully-qualified # 🎂 birthday cake +"🍰", // 1F370 ; fully-qualified # 🍰 shortcake +"🧁", // 1F9C1 ; fully-qualified # 🧁 cupcake +"🥧", // 1F967 ; fully-qualified # 🥧 pie +"🍫", // 1F36B ; fully-qualified # 🍫 chocolate bar +"🍬", // 1F36C ; fully-qualified # 🍬 candy +"🍭", // 1F36D ; fully-qualified # 🍭 lollipop +"🍮", // 1F36E ; fully-qualified # 🍮 custard +"🍯", // 1F36F ; fully-qualified # 🍯 honey pot +"🍼", // 1F37C ; fully-qualified # 🍼 baby bottle +"🥛", // 1F95B ; fully-qualified # 🥛 glass of milk +"☕", // 2615 ; fully-qualified # ☕ hot beverage +"🍵", // 1F375 ; fully-qualified # 🍵 teacup without handle +"🍶", // 1F376 ; fully-qualified # 🍶 sake +"🍾", // 1F37E ; fully-qualified # 🍾 bottle with popping cork +"🍷", // 1F377 ; fully-qualified # 🍷 wine glass +"🍸", // 1F378 ; fully-qualified # 🍸 cocktail glass +"🍹", // 1F379 ; fully-qualified # 🍹 tropical drink +"🍺", // 1F37A ; fully-qualified # 🍺 beer mug +"🍻", // 1F37B ; fully-qualified # 🍻 clinking beer mugs +"🥂", // 1F942 ; fully-qualified # 🥂 clinking glasses +"🥃", // 1F943 ; fully-qualified # 🥃 tumbler glass +"🥤", // 1F964 ; fully-qualified # 🥤 cup with straw +"🧃", // 1F9C3 ; fully-qualified # 🧃 beverage box +"🧉", // 1F9C9 ; fully-qualified # 🧉 mate +"🧊", // 1F9CA ; fully-qualified # 🧊 ice cube +"🥢", // 1F962 ; fully-qualified # 🥢 chopsticks +"🍴", // 1F374 ; fully-qualified # 🍴 fork and knife +"🥄", // 1F944 ; fully-qualified # 🥄 spoon +"🔪", // 1F52A ; fully-qualified # 🔪 kitchen knife +"🏺", // 1F3FA ; fully-qualified # 🏺 amphora +}, +{ // # group: Travel & Places +"🌍", // 1F30D ; fully-qualified # 🌍 globe showing Europe-Africa +"🌎", // 1F30E ; fully-qualified # 🌎 globe showing Americas +"🌏", // 1F30F ; fully-qualified # 🌏 globe showing Asia-Australia +"🌐", // 1F310 ; fully-qualified # 🌐 globe with meridians +"🗾", // 1F5FE ; fully-qualified # 🗾 map of Japan +"🧭", // 1F9ED ; fully-qualified # 🧭 compass +"🌋", // 1F30B ; fully-qualified # 🌋 volcano +"🗻", // 1F5FB ; fully-qualified # 🗻 mount fuji +"🧱", // 1F9F1 ; fully-qualified # 🧱 brick +"🏠", // 1F3E0 ; fully-qualified # 🏠 house +"🏡", // 1F3E1 ; fully-qualified # 🏡 house with garden +"🏢", // 1F3E2 ; fully-qualified # 🏢 office building +"🏣", // 1F3E3 ; fully-qualified # 🏣 Japanese post office +"🏤", // 1F3E4 ; fully-qualified # 🏤 post office +"🏥", // 1F3E5 ; fully-qualified # 🏥 hospital +"🏦", // 1F3E6 ; fully-qualified # 🏦 bank +"🏨", // 1F3E8 ; fully-qualified # 🏨 hotel +"🏩", // 1F3E9 ; fully-qualified # 🏩 love hotel +"🏪", // 1F3EA ; fully-qualified # 🏪 convenience store +"🏫", // 1F3EB ; fully-qualified # 🏫 school +"🏬", // 1F3EC ; fully-qualified # 🏬 department store +"🏭", // 1F3ED ; fully-qualified # 🏭 factory +"🏯", // 1F3EF ; fully-qualified # 🏯 Japanese castle +"🏰", // 1F3F0 ; fully-qualified # 🏰 castle +"💒", // 1F492 ; fully-qualified # 💒 wedding +"🗼", // 1F5FC ; fully-qualified # 🗼 Tokyo tower +"🗽", // 1F5FD ; fully-qualified # 🗽 Statue of Liberty +"⛪", // 26EA ; fully-qualified # ⛪ church +"🕌", // 1F54C ; fully-qualified # 🕌 mosque +"🛕", // 1F6D5 ; fully-qualified # 🛕 hindu temple +"🕍", // 1F54D ; fully-qualified # 🕍 synagogue +"🕋", // 1F54B ; fully-qualified # 🕋 kaaba +"⛲", // 26F2 ; fully-qualified # ⛲ fountain +"⛺", // 26FA ; fully-qualified # ⛺ tent +"🌁", // 1F301 ; fully-qualified # 🌁 foggy +"🌃", // 1F303 ; fully-qualified # 🌃 night with stars +"🌄", // 1F304 ; fully-qualified # 🌄 sunrise over mountains +"🌅", // 1F305 ; fully-qualified # 🌅 sunrise +"🌆", // 1F306 ; fully-qualified # 🌆 cityscape at dusk +"🌇", // 1F307 ; fully-qualified # 🌇 sunset +"🌉", // 1F309 ; fully-qualified # 🌉 bridge at night +"🎠", // 1F3A0 ; fully-qualified # 🎠 carousel horse +"🎡", // 1F3A1 ; fully-qualified # 🎡 ferris wheel +"🎢", // 1F3A2 ; fully-qualified # 🎢 roller coaster +"💈", // 1F488 ; fully-qualified # 💈 barber pole +"🎪", // 1F3AA ; fully-qualified # 🎪 circus tent +"🚂", // 1F682 ; fully-qualified # 🚂 locomotive +"🚃", // 1F683 ; fully-qualified # 🚃 railway car +"🚄", // 1F684 ; fully-qualified # 🚄 high-speed train +"🚅", // 1F685 ; fully-qualified # 🚅 bullet train +"🚆", // 1F686 ; fully-qualified # 🚆 train +"🚇", // 1F687 ; fully-qualified # 🚇 metro +"🚈", // 1F688 ; fully-qualified # 🚈 light rail +"🚉", // 1F689 ; fully-qualified # 🚉 station +"🚊", // 1F68A ; fully-qualified # 🚊 tram +"🚝", // 1F69D ; fully-qualified # 🚝 monorail +"🚞", // 1F69E ; fully-qualified # 🚞 mountain railway +"🚋", // 1F68B ; fully-qualified # 🚋 tram car +"🚌", // 1F68C ; fully-qualified # 🚌 bus +"🚍", // 1F68D ; fully-qualified # 🚍 oncoming bus +"🚎", // 1F68E ; fully-qualified # 🚎 trolleybus +"🚐", // 1F690 ; fully-qualified # 🚐 minibus +"🚑", // 1F691 ; fully-qualified # 🚑 ambulance +"🚒", // 1F692 ; fully-qualified # 🚒 fire engine +"🚓", // 1F693 ; fully-qualified # 🚓 police car +"🚔", // 1F694 ; fully-qualified # 🚔 oncoming police car +"🚕", // 1F695 ; fully-qualified # 🚕 taxi +"🚖", // 1F696 ; fully-qualified # 🚖 oncoming taxi +"🚗", // 1F697 ; fully-qualified # 🚗 automobile +"🚘", // 1F698 ; fully-qualified # 🚘 oncoming automobile +"🚙", // 1F699 ; fully-qualified # 🚙 sport utility vehicle +"🚚", // 1F69A ; fully-qualified # 🚚 delivery truck +"🚛", // 1F69B ; fully-qualified # 🚛 articulated lorry +"🚜", // 1F69C ; fully-qualified # 🚜 tractor +"🛵", // 1F6F5 ; fully-qualified # 🛵 motor scooter +"🦽", // 1F9BD ; fully-qualified # 🦽 manual wheelchair +"🦼", // 1F9BC ; fully-qualified # 🦼 motorized wheelchair +"🛺", // 1F6FA ; fully-qualified # 🛺 auto rickshaw +"🚲", // 1F6B2 ; fully-qualified # 🚲 bicycle +"🛴", // 1F6F4 ; fully-qualified # 🛴 kick scooter +"🛹", // 1F6F9 ; fully-qualified # 🛹 skateboard +"🚏", // 1F68F ; fully-qualified # 🚏 bus stop +"⛽", // 26FD ; fully-qualified # ⛽ fuel pump +"🚨", // 1F6A8 ; fully-qualified # 🚨 police car light +"🚥", // 1F6A5 ; fully-qualified # 🚥 horizontal traffic light +"🚦", // 1F6A6 ; fully-qualified # 🚦 vertical traffic light +"🛑", // 1F6D1 ; fully-qualified # 🛑 stop sign +"🚧", // 1F6A7 ; fully-qualified # 🚧 construction +"⚓", // 2693 ; fully-qualified # ⚓ anchor +"⛵", // 26F5 ; fully-qualified # ⛵ sailboat +"🛶", // 1F6F6 ; fully-qualified # 🛶 canoe +"🚤", // 1F6A4 ; fully-qualified # 🚤 speedboat +"🚢", // 1F6A2 ; fully-qualified # 🚢 ship +"🛫", // 1F6EB ; fully-qualified # 🛫 airplane departure +"🛬", // 1F6EC ; fully-qualified # 🛬 airplane arrival +"🪂", // 1FA82 ; fully-qualified # 🪂 parachute +"💺", // 1F4BA ; fully-qualified # 💺 seat +"🚁", // 1F681 ; fully-qualified # 🚁 helicopter +"🚟", // 1F69F ; fully-qualified # 🚟 suspension railway +"🚠", // 1F6A0 ; fully-qualified # 🚠 mountain cableway +"🚡", // 1F6A1 ; fully-qualified # 🚡 aerial tramway +"🚀", // 1F680 ; fully-qualified # 🚀 rocket +"🛸", // 1F6F8 ; fully-qualified # 🛸 flying saucer +"🧳", // 1F9F3 ; fully-qualified # 🧳 luggage +"⌛", // 231B ; fully-qualified # ⌛ hourglass done +"⏳", // 23F3 ; fully-qualified # ⏳ hourglass not done +"⌚", // 231A ; fully-qualified # ⌚ watch +"⏰", // 23F0 ; fully-qualified # ⏰ alarm clock +"🕛", // 1F55B ; fully-qualified # 🕛 twelve o’clock +"🕧", // 1F567 ; fully-qualified # 🕧 twelve-thirty +"🕐", // 1F550 ; fully-qualified # 🕐 one o’clock +"🕜", // 1F55C ; fully-qualified # 🕜 one-thirty +"🕑", // 1F551 ; fully-qualified # 🕑 two o’clock +"🕝", // 1F55D ; fully-qualified # 🕝 two-thirty +"🕒", // 1F552 ; fully-qualified # 🕒 three o’clock +"🕞", // 1F55E ; fully-qualified # 🕞 three-thirty +"🕓", // 1F553 ; fully-qualified # 🕓 four o’clock +"🕟", // 1F55F ; fully-qualified # 🕟 four-thirty +"🕔", // 1F554 ; fully-qualified # 🕔 five o’clock +"🕠", // 1F560 ; fully-qualified # 🕠 five-thirty +"🕕", // 1F555 ; fully-qualified # 🕕 six o’clock +"🕡", // 1F561 ; fully-qualified # 🕡 six-thirty +"🕖", // 1F556 ; fully-qualified # 🕖 seven o’clock +"🕢", // 1F562 ; fully-qualified # 🕢 seven-thirty +"🕗", // 1F557 ; fully-qualified # 🕗 eight o’clock +"🕣", // 1F563 ; fully-qualified # 🕣 eight-thirty +"🕘", // 1F558 ; fully-qualified # 🕘 nine o’clock +"🕤", // 1F564 ; fully-qualified # 🕤 nine-thirty +"🕙", // 1F559 ; fully-qualified # 🕙 ten o’clock +"🕥", // 1F565 ; fully-qualified # 🕥 ten-thirty +"🕚", // 1F55A ; fully-qualified # 🕚 eleven o’clock +"🕦", // 1F566 ; fully-qualified # 🕦 eleven-thirty +"🌑", // 1F311 ; fully-qualified # 🌑 new moon +"🌒", // 1F312 ; fully-qualified # 🌒 waxing crescent moon +"🌓", // 1F313 ; fully-qualified # 🌓 first quarter moon +"🌔", // 1F314 ; fully-qualified # 🌔 waxing gibbous moon +"🌕", // 1F315 ; fully-qualified # 🌕 full moon +"🌖", // 1F316 ; fully-qualified # 🌖 waning gibbous moon +"🌗", // 1F317 ; fully-qualified # 🌗 last quarter moon +"🌘", // 1F318 ; fully-qualified # 🌘 waning crescent moon +"🌙", // 1F319 ; fully-qualified # 🌙 crescent moon +"🌚", // 1F31A ; fully-qualified # 🌚 new moon face +"🌛", // 1F31B ; fully-qualified # 🌛 first quarter moon face +"🌜", // 1F31C ; fully-qualified # 🌜 last quarter moon face +"🌝", // 1F31D ; fully-qualified # 🌝 full moon face +"🌞", // 1F31E ; fully-qualified # 🌞 sun with face +"🪐", // 1FA90 ; fully-qualified # 🪐 ringed planet +"⭐", // 2B50 ; fully-qualified # ⭐ star +"🌟", // 1F31F ; fully-qualified # 🌟 glowing star +"🌠", // 1F320 ; fully-qualified # 🌠 shooting star +"🌌", // 1F30C ; fully-qualified # 🌌 milky way +"⛅", // 26C5 ; fully-qualified # ⛅ sun behind cloud +"🌀", // 1F300 ; fully-qualified # 🌀 cyclone +"🌈", // 1F308 ; fully-qualified # 🌈 rainbow +"🌂", // 1F302 ; fully-qualified # 🌂 closed umbrella +"☔", // 2614 ; fully-qualified # ☔ umbrella with rain drops +"⚡", // 26A1 ; fully-qualified # ⚡ high voltage +"⛄", // 26C4 ; fully-qualified # ⛄ snowman without snow +"🔥", // 1F525 ; fully-qualified # 🔥 fire +"💧", // 1F4A7 ; fully-qualified # 💧 droplet +"🌊", // 1F30A ; fully-qualified # 🌊 water wave +}, +{ // # group: Activities +"🎃", // 1F383 ; fully-qualified # 🎃 jack-o-lantern +"🎄", // 1F384 ; fully-qualified # 🎄 Christmas tree +"🎆", // 1F386 ; fully-qualified # 🎆 fireworks +"🎇", // 1F387 ; fully-qualified # 🎇 sparkler +"🧨", // 1F9E8 ; fully-qualified # 🧨 firecracker +"✨", // 2728 ; fully-qualified # ✨ sparkles +"🎈", // 1F388 ; fully-qualified # 🎈 balloon +"🎉", // 1F389 ; fully-qualified # 🎉 party popper +"🎊", // 1F38A ; fully-qualified # 🎊 confetti ball +"🎋", // 1F38B ; fully-qualified # 🎋 tanabata tree +"🎍", // 1F38D ; fully-qualified # 🎍 pine decoration +"🎎", // 1F38E ; fully-qualified # 🎎 Japanese dolls +"🎏", // 1F38F ; fully-qualified # 🎏 carp streamer +"🎐", // 1F390 ; fully-qualified # 🎐 wind chime +"🎑", // 1F391 ; fully-qualified # 🎑 moon viewing ceremony +"🧧", // 1F9E7 ; fully-qualified # 🧧 red envelope +"🎀", // 1F380 ; fully-qualified # 🎀 ribbon +"🎁", // 1F381 ; fully-qualified # 🎁 wrapped gift +"🎫", // 1F3AB ; fully-qualified # 🎫 ticket +"🏆", // 1F3C6 ; fully-qualified # 🏆 trophy +"🏅", // 1F3C5 ; fully-qualified # 🏅 sports medal +"🥇", // 1F947 ; fully-qualified # 🥇 1st place medal +"🥈", // 1F948 ; fully-qualified # 🥈 2nd place medal +"🥉", // 1F949 ; fully-qualified # 🥉 3rd place medal +"⚽", // 26BD ; fully-qualified # ⚽ soccer ball +"⚾", // 26BE ; fully-qualified # ⚾ baseball +"🥎", // 1F94E ; fully-qualified # 🥎 softball +"🏀", // 1F3C0 ; fully-qualified # 🏀 basketball +"🏐", // 1F3D0 ; fully-qualified # 🏐 volleyball +"🏈", // 1F3C8 ; fully-qualified # 🏈 american football +"🏉", // 1F3C9 ; fully-qualified # 🏉 rugby football +"🎾", // 1F3BE ; fully-qualified # 🎾 tennis +"🥏", // 1F94F ; fully-qualified # 🥏 flying disc +"🎳", // 1F3B3 ; fully-qualified # 🎳 bowling +"🏏", // 1F3CF ; fully-qualified # 🏏 cricket game +"🏑", // 1F3D1 ; fully-qualified # 🏑 field hockey +"🏒", // 1F3D2 ; fully-qualified # 🏒 ice hockey +"🥍", // 1F94D ; fully-qualified # 🥍 lacrosse +"🏓", // 1F3D3 ; fully-qualified # 🏓 ping pong +"🏸", // 1F3F8 ; fully-qualified # 🏸 badminton +"🥊", // 1F94A ; fully-qualified # 🥊 boxing glove +"🥋", // 1F94B ; fully-qualified # 🥋 martial arts uniform +"🥅", // 1F945 ; fully-qualified # 🥅 goal net +"⛳", // 26F3 ; fully-qualified # ⛳ flag in hole +"🎣", // 1F3A3 ; fully-qualified # 🎣 fishing pole +"🤿", // 1F93F ; fully-qualified # 🤿 diving mask +"🎽", // 1F3BD ; fully-qualified # 🎽 running shirt +"🎿", // 1F3BF ; fully-qualified # 🎿 skis +"🛷", // 1F6F7 ; fully-qualified # 🛷 sled +"🥌", // 1F94C ; fully-qualified # 🥌 curling stone +"🎯", // 1F3AF ; fully-qualified # 🎯 direct hit +"🪀", // 1FA80 ; fully-qualified # 🪀 yo-yo +"🪁", // 1FA81 ; fully-qualified # 🪁 kite +"🎱", // 1F3B1 ; fully-qualified # 🎱 pool 8 ball +"🔮", // 1F52E ; fully-qualified # 🔮 crystal ball +"🧿", // 1F9FF ; fully-qualified # 🧿 nazar amulet +"🎮", // 1F3AE ; fully-qualified # 🎮 video game +"🎰", // 1F3B0 ; fully-qualified # 🎰 slot machine +"🎲", // 1F3B2 ; fully-qualified # 🎲 game die +"🧩", // 1F9E9 ; fully-qualified # 🧩 puzzle piece +"🧸", // 1F9F8 ; fully-qualified # 🧸 teddy bear +"🃏", // 1F0CF ; fully-qualified # 🃏 joker +"🀄", // 1F004 ; fully-qualified # 🀄 mahjong red dragon +"🎴", // 1F3B4 ; fully-qualified # 🎴 flower playing cards +"🎭", // 1F3AD ; fully-qualified # 🎭 performing arts +"🎨", // 1F3A8 ; fully-qualified # 🎨 artist palette +"🧵", // 1F9F5 ; fully-qualified # 🧵 thread +"🧶", // 1F9F6 ; fully-qualified # 🧶 yarn +}, +{ // # group: Objects +"👓", // 1F453 ; fully-qualified # 👓 glasses +"🥽", // 1F97D ; fully-qualified # 🥽 goggles +"🥼", // 1F97C ; fully-qualified # 🥼 lab coat +"🦺", // 1F9BA ; fully-qualified # 🦺 safety vest +"👔", // 1F454 ; fully-qualified # 👔 necktie +"👕", // 1F455 ; fully-qualified # 👕 t-shirt +"👖", // 1F456 ; fully-qualified # 👖 jeans +"🧣", // 1F9E3 ; fully-qualified # 🧣 scarf +"🧤", // 1F9E4 ; fully-qualified # 🧤 gloves +"🧥", // 1F9E5 ; fully-qualified # 🧥 coat +"🧦", // 1F9E6 ; fully-qualified # 🧦 socks +"👗", // 1F457 ; fully-qualified # 👗 dress +"👘", // 1F458 ; fully-qualified # 👘 kimono +"🥻", // 1F97B ; fully-qualified # 🥻 sari +"🩱", // 1FA71 ; fully-qualified # 🩱 one-piece swimsuit +"🩲", // 1FA72 ; fully-qualified # 🩲 swim brief +"🩳", // 1FA73 ; fully-qualified # 🩳 shorts +"👙", // 1F459 ; fully-qualified # 👙 bikini +"👚", // 1F45A ; fully-qualified # 👚 woman’s clothes +"👛", // 1F45B ; fully-qualified # 👛 purse +"👜", // 1F45C ; fully-qualified # 👜 handbag +"👝", // 1F45D ; fully-qualified # 👝 clutch bag +"🎒", // 1F392 ; fully-qualified # 🎒 backpack +"👞", // 1F45E ; fully-qualified # 👞 man’s shoe +"👟", // 1F45F ; fully-qualified # 👟 running shoe +"🥾", // 1F97E ; fully-qualified # 🥾 hiking boot +"🥿", // 1F97F ; fully-qualified # 🥿 flat shoe +"👠", // 1F460 ; fully-qualified # 👠 high-heeled shoe +"👡", // 1F461 ; fully-qualified # 👡 woman’s sandal +"🩰", // 1FA70 ; fully-qualified # 🩰 ballet shoes +"👢", // 1F462 ; fully-qualified # 👢 woman’s boot +"👑", // 1F451 ; fully-qualified # 👑 crown +"👒", // 1F452 ; fully-qualified # 👒 woman’s hat +"🎩", // 1F3A9 ; fully-qualified # 🎩 top hat +"🎓", // 1F393 ; fully-qualified # 🎓 graduation cap +"🧢", // 1F9E2 ; fully-qualified # 🧢 billed cap +"📿", // 1F4FF ; fully-qualified # 📿 prayer beads +"💄", // 1F484 ; fully-qualified # 💄 lipstick +"💍", // 1F48D ; fully-qualified # 💍 ring +"💎", // 1F48E ; fully-qualified # 💎 gem stone +"🔇", // 1F507 ; fully-qualified # 🔇 muted speaker +"🔈", // 1F508 ; fully-qualified # 🔈 speaker low volume +"🔉", // 1F509 ; fully-qualified # 🔉 speaker medium volume +"🔊", // 1F50A ; fully-qualified # 🔊 speaker high volume +"📢", // 1F4E2 ; fully-qualified # 📢 loudspeaker +"📣", // 1F4E3 ; fully-qualified # 📣 megaphone +"📯", // 1F4EF ; fully-qualified # 📯 postal horn +"🔔", // 1F514 ; fully-qualified # 🔔 bell +"🔕", // 1F515 ; fully-qualified # 🔕 bell with slash +"🎼", // 1F3BC ; fully-qualified # 🎼 musical score +"🎵", // 1F3B5 ; fully-qualified # 🎵 musical note +"🎶", // 1F3B6 ; fully-qualified # 🎶 musical notes +"🎤", // 1F3A4 ; fully-qualified # 🎤 microphone +"🎧", // 1F3A7 ; fully-qualified # 🎧 headphone +"📻", // 1F4FB ; fully-qualified # 📻 radio +"🎷", // 1F3B7 ; fully-qualified # 🎷 saxophone +"🎸", // 1F3B8 ; fully-qualified # 🎸 guitar +"🎹", // 1F3B9 ; fully-qualified # 🎹 musical keyboard +"🎺", // 1F3BA ; fully-qualified # 🎺 trumpet +"🎻", // 1F3BB ; fully-qualified # 🎻 violin +"🪕", // 1FA95 ; fully-qualified # 🪕 banjo +"🥁", // 1F941 ; fully-qualified # 🥁 drum +"📱", // 1F4F1 ; fully-qualified # 📱 mobile phone +"📲", // 1F4F2 ; fully-qualified # 📲 mobile phone with arrow +"📞", // 1F4DE ; fully-qualified # 📞 telephone receiver +"📟", // 1F4DF ; fully-qualified # 📟 pager +"📠", // 1F4E0 ; fully-qualified # 📠 fax machine +"🔋", // 1F50B ; fully-qualified # 🔋 battery +"🔌", // 1F50C ; fully-qualified # 🔌 electric plug +"💻", // 1F4BB ; fully-qualified # 💻 laptop computer +"💽", // 1F4BD ; fully-qualified # 💽 computer disk +"💾", // 1F4BE ; fully-qualified # 💾 floppy disk +"💿", // 1F4BF ; fully-qualified # 💿 optical disk +"📀", // 1F4C0 ; fully-qualified # 📀 dvd +"🧮", // 1F9EE ; fully-qualified # 🧮 abacus +"🎥", // 1F3A5 ; fully-qualified # 🎥 movie camera +"🎬", // 1F3AC ; fully-qualified # 🎬 clapper board +"📺", // 1F4FA ; fully-qualified # 📺 television +"📷", // 1F4F7 ; fully-qualified # 📷 camera +"📸", // 1F4F8 ; fully-qualified # 📸 camera with flash +"📹", // 1F4F9 ; fully-qualified # 📹 video camera +"📼", // 1F4FC ; fully-qualified # 📼 videocassette +"🔍", // 1F50D ; fully-qualified # 🔍 magnifying glass tilted left +"🔎", // 1F50E ; fully-qualified # 🔎 magnifying glass tilted right +"💡", // 1F4A1 ; fully-qualified # 💡 light bulb +"🔦", // 1F526 ; fully-qualified # 🔦 flashlight +"🏮", // 1F3EE ; fully-qualified # 🏮 red paper lantern +"🪔", // 1FA94 ; fully-qualified # 🪔 diya lamp +"📔", // 1F4D4 ; fully-qualified # 📔 notebook with decorative cover +"📕", // 1F4D5 ; fully-qualified # 📕 closed book +"📖", // 1F4D6 ; fully-qualified # 📖 open book +"📗", // 1F4D7 ; fully-qualified # 📗 green book +"📘", // 1F4D8 ; fully-qualified # 📘 blue book +"📙", // 1F4D9 ; fully-qualified # 📙 orange book +"📚", // 1F4DA ; fully-qualified # 📚 books +"📓", // 1F4D3 ; fully-qualified # 📓 notebook +"📒", // 1F4D2 ; fully-qualified # 📒 ledger +"📃", // 1F4C3 ; fully-qualified # 📃 page with curl +"📜", // 1F4DC ; fully-qualified # 📜 scroll +"📄", // 1F4C4 ; fully-qualified # 📄 page facing up +"📰", // 1F4F0 ; fully-qualified # 📰 newspaper +"📑", // 1F4D1 ; fully-qualified # 📑 bookmark tabs +"🔖", // 1F516 ; fully-qualified # 🔖 bookmark +"💰", // 1F4B0 ; fully-qualified # 💰 money bag +"💴", // 1F4B4 ; fully-qualified # 💴 yen banknote +"💵", // 1F4B5 ; fully-qualified # 💵 dollar banknote +"💶", // 1F4B6 ; fully-qualified # 💶 euro banknote +"💷", // 1F4B7 ; fully-qualified # 💷 pound banknote +"💸", // 1F4B8 ; fully-qualified # 💸 money with wings +"💳", // 1F4B3 ; fully-qualified # 💳 credit card +"🧾", // 1F9FE ; fully-qualified # 🧾 receipt +"💹", // 1F4B9 ; fully-qualified # 💹 chart increasing with yen +"💱", // 1F4B1 ; fully-qualified # 💱 currency exchange +"💲", // 1F4B2 ; fully-qualified # 💲 heavy dollar sign +"📧", // 1F4E7 ; fully-qualified # 📧 e-mail +"📨", // 1F4E8 ; fully-qualified # 📨 incoming envelope +"📩", // 1F4E9 ; fully-qualified # 📩 envelope with arrow +"📤", // 1F4E4 ; fully-qualified # 📤 outbox tray +"📥", // 1F4E5 ; fully-qualified # 📥 inbox tray +"📦", // 1F4E6 ; fully-qualified # 📦 package +"📫", // 1F4EB ; fully-qualified # 📫 closed mailbox with raised flag +"📪", // 1F4EA ; fully-qualified # 📪 closed mailbox with lowered flag +"📬", // 1F4EC ; fully-qualified # 📬 open mailbox with raised flag +"📭", // 1F4ED ; fully-qualified # 📭 open mailbox with lowered flag +"📮", // 1F4EE ; fully-qualified # 📮 postbox +"📝", // 1F4DD ; fully-qualified # 📝 memo +"💼", // 1F4BC ; fully-qualified # 💼 briefcase +"📁", // 1F4C1 ; fully-qualified # 📁 file folder +"📂", // 1F4C2 ; fully-qualified # 📂 open file folder +"📅", // 1F4C5 ; fully-qualified # 📅 calendar +"📆", // 1F4C6 ; fully-qualified # 📆 tear-off calendar +"📇", // 1F4C7 ; fully-qualified # 📇 card index +"📈", // 1F4C8 ; fully-qualified # 📈 chart increasing +"📉", // 1F4C9 ; fully-qualified # 📉 chart decreasing +"📊", // 1F4CA ; fully-qualified # 📊 bar chart +"📋", // 1F4CB ; fully-qualified # 📋 clipboard +"📌", // 1F4CC ; fully-qualified # 📌 pushpin +"📍", // 1F4CD ; fully-qualified # 📍 round pushpin +"📎", // 1F4CE ; fully-qualified # 📎 paperclip +"📏", // 1F4CF ; fully-qualified # 📏 straight ruler +"📐", // 1F4D0 ; fully-qualified # 📐 triangular ruler +"🔒", // 1F512 ; fully-qualified # 🔒 locked +"🔓", // 1F513 ; fully-qualified # 🔓 unlocked +"🔏", // 1F50F ; fully-qualified # 🔏 locked with pen +"🔐", // 1F510 ; fully-qualified # 🔐 locked with key +"🔑", // 1F511 ; fully-qualified # 🔑 key +"🔨", // 1F528 ; fully-qualified # 🔨 hammer +"🪓", // 1FA93 ; fully-qualified # 🪓 axe +"🔫", // 1F52B ; fully-qualified # 🔫 pistol +"🏹", // 1F3F9 ; fully-qualified # 🏹 bow and arrow +"🔧", // 1F527 ; fully-qualified # 🔧 wrench +"🔩", // 1F529 ; fully-qualified # 🔩 nut and bolt +"🦯", // 1F9AF ; fully-qualified # 🦯 probing cane +"🔗", // 1F517 ; fully-qualified # 🔗 link +"🧰", // 1F9F0 ; fully-qualified # 🧰 toolbox +"🧲", // 1F9F2 ; fully-qualified # 🧲 magnet +"🧪", // 1F9EA ; fully-qualified # 🧪 test tube +"🧫", // 1F9EB ; fully-qualified # 🧫 petri dish +"🧬", // 1F9EC ; fully-qualified # 🧬 dna +"🔬", // 1F52C ; fully-qualified # 🔬 microscope +"🔭", // 1F52D ; fully-qualified # 🔭 telescope +"📡", // 1F4E1 ; fully-qualified # 📡 satellite antenna +"💉", // 1F489 ; fully-qualified # 💉 syringe +"🩸", // 1FA78 ; fully-qualified # 🩸 drop of blood +"💊", // 1F48A ; fully-qualified # 💊 pill +"🩹", // 1FA79 ; fully-qualified # 🩹 adhesive bandage +"🩺", // 1FA7A ; fully-qualified # 🩺 stethoscope +"🚪", // 1F6AA ; fully-qualified # 🚪 door +"🪑", // 1FA91 ; fully-qualified # 🪑 chair +"🚽", // 1F6BD ; fully-qualified # 🚽 toilet +"🚿", // 1F6BF ; fully-qualified # 🚿 shower +"🛁", // 1F6C1 ; fully-qualified # 🛁 bathtub +"🪒", // 1FA92 ; fully-qualified # 🪒 razor +"🧴", // 1F9F4 ; fully-qualified # 🧴 lotion bottle +"🧷", // 1F9F7 ; fully-qualified # 🧷 safety pin +"🧹", // 1F9F9 ; fully-qualified # 🧹 broom +"🧺", // 1F9FA ; fully-qualified # 🧺 basket +"🧻", // 1F9FB ; fully-qualified # 🧻 roll of paper +"🧼", // 1F9FC ; fully-qualified # 🧼 soap +"🧽", // 1F9FD ; fully-qualified # 🧽 sponge +"🧯", // 1F9EF ; fully-qualified # 🧯 fire extinguisher +"🛒", // 1F6D2 ; fully-qualified # 🛒 shopping cart +"🚬", // 1F6AC ; fully-qualified # 🚬 cigarette +"🗿", // 1F5FF ; fully-qualified # 🗿 moai +}, +{ // # group: Symbols +"🏧", // 1F3E7 ; fully-qualified # 🏧 ATM sign +"🚮", // 1F6AE ; fully-qualified # 🚮 litter in bin sign +"🚰", // 1F6B0 ; fully-qualified # 🚰 potable water +"♿", // 267F ; fully-qualified # ♿ wheelchair symbol +"🚹", // 1F6B9 ; fully-qualified # 🚹 men’s room +"🚺", // 1F6BA ; fully-qualified # 🚺 women’s room +"🚻", // 1F6BB ; fully-qualified # 🚻 restroom +"🚼", // 1F6BC ; fully-qualified # 🚼 baby symbol +"🚾", // 1F6BE ; fully-qualified # 🚾 water closet +"🛂", // 1F6C2 ; fully-qualified # 🛂 passport control +"🛃", // 1F6C3 ; fully-qualified # 🛃 customs +"🛄", // 1F6C4 ; fully-qualified # 🛄 baggage claim +"🛅", // 1F6C5 ; fully-qualified # 🛅 left luggage +"🚸", // 1F6B8 ; fully-qualified # 🚸 children crossing +"⛔", // 26D4 ; fully-qualified # ⛔ no entry +"🚫", // 1F6AB ; fully-qualified # 🚫 prohibited +"🚳", // 1F6B3 ; fully-qualified # 🚳 no bicycles +"🚭", // 1F6AD ; fully-qualified # 🚭 no smoking +"🚯", // 1F6AF ; fully-qualified # 🚯 no littering +"🚱", // 1F6B1 ; fully-qualified # 🚱 non-potable water +"🚷", // 1F6B7 ; fully-qualified # 🚷 no pedestrians +"📵", // 1F4F5 ; fully-qualified # 📵 no mobile phones +"🔞", // 1F51E ; fully-qualified # 🔞 no one under eighteen +"🔃", // 1F503 ; fully-qualified # 🔃 clockwise vertical arrows +"🔄", // 1F504 ; fully-qualified # 🔄 counterclockwise arrows button +"🔙", // 1F519 ; fully-qualified # 🔙 BACK arrow +"🔚", // 1F51A ; fully-qualified # 🔚 END arrow +"🔛", // 1F51B ; fully-qualified # 🔛 ON! arrow +"🔜", // 1F51C ; fully-qualified # 🔜 SOON arrow +"🔝", // 1F51D ; fully-qualified # 🔝 TOP arrow +"🛐", // 1F6D0 ; fully-qualified # 🛐 place of worship +"🕎", // 1F54E ; fully-qualified # 🕎 menorah +"🔯", // 1F52F ; fully-qualified # 🔯 dotted six-pointed star +"♈", // 2648 ; fully-qualified # ♈ Aries +"♉", // 2649 ; fully-qualified # ♉ Taurus +"♊", // 264A ; fully-qualified # ♊ Gemini +"♋", // 264B ; fully-qualified # ♋ Cancer +"♌", // 264C ; fully-qualified # ♌ Leo +"♍", // 264D ; fully-qualified # ♍ Virgo +"♎", // 264E ; fully-qualified # ♎ Libra +"♏", // 264F ; fully-qualified # ♏ Scorpio +"♐", // 2650 ; fully-qualified # ♐ Sagittarius +"♑", // 2651 ; fully-qualified # ♑ Capricorn +"♒", // 2652 ; fully-qualified # ♒ Aquarius +"♓", // 2653 ; fully-qualified # ♓ Pisces +"⛎", // 26CE ; fully-qualified # ⛎ Ophiuchus +"🔀", // 1F500 ; fully-qualified # 🔀 shuffle tracks button +"🔁", // 1F501 ; fully-qualified # 🔁 repeat button +"🔂", // 1F502 ; fully-qualified # 🔂 repeat single button +"⏩", // 23E9 ; fully-qualified # ⏩ fast-forward button +"⏪", // 23EA ; fully-qualified # ⏪ fast reverse button +"🔼", // 1F53C ; fully-qualified # 🔼 upwards button +"⏫", // 23EB ; fully-qualified # ⏫ fast up button +"🔽", // 1F53D ; fully-qualified # 🔽 downwards button +"⏬", // 23EC ; fully-qualified # ⏬ fast down button +"🎦", // 1F3A6 ; fully-qualified # 🎦 cinema +"🔅", // 1F505 ; fully-qualified # 🔅 dim button +"🔆", // 1F506 ; fully-qualified # 🔆 bright button +"📶", // 1F4F6 ; fully-qualified # 📶 antenna bars +"📳", // 1F4F3 ; fully-qualified # 📳 vibration mode +"📴", // 1F4F4 ; fully-qualified # 📴 mobile phone off +"🔱", // 1F531 ; fully-qualified # 🔱 trident emblem +"📛", // 1F4DB ; fully-qualified # 📛 name badge +"🔰", // 1F530 ; fully-qualified # 🔰 Japanese symbol for beginner +"⭕", // 2B55 ; fully-qualified # ⭕ hollow red circle +"✅", // 2705 ; fully-qualified # ✅ check mark button +"❌", // 274C ; fully-qualified # ❌ cross mark +"❎", // 274E ; fully-qualified # ❎ cross mark button +"➕", // 2795 ; fully-qualified # ➕ plus sign +"➖", // 2796 ; fully-qualified # ➖ minus sign +"➗", // 2797 ; fully-qualified # ➗ division sign +"➰", // 27B0 ; fully-qualified # ➰ curly loop +"➿", // 27BF ; fully-qualified # ➿ double curly loop +"❓", // 2753 ; fully-qualified # ❓ question mark +"❔", // 2754 ; fully-qualified # ❔ white question mark +"❕", // 2755 ; fully-qualified # ❕ white exclamation mark +"❗", // 2757 ; fully-qualified # ❗ exclamation mark +"🔟", // 1F51F ; fully-qualified # 🔟 keycap: 10 +"🔠", // 1F520 ; fully-qualified # 🔠 input latin uppercase +"🔡", // 1F521 ; fully-qualified # 🔡 input latin lowercase +"🔢", // 1F522 ; fully-qualified # 🔢 input numbers +"🔣", // 1F523 ; fully-qualified # 🔣 input symbols +"🔤", // 1F524 ; fully-qualified # 🔤 input latin letters +"🆎", // 1F18E ; fully-qualified # 🆎 AB button (blood type) +"🆑", // 1F191 ; fully-qualified # 🆑 CL button +"🆒", // 1F192 ; fully-qualified # 🆒 COOL button +"🆓", // 1F193 ; fully-qualified # 🆓 FREE button +"🆔", // 1F194 ; fully-qualified # 🆔 ID button +"🆕", // 1F195 ; fully-qualified # 🆕 NEW button +"🆖", // 1F196 ; fully-qualified # 🆖 NG button +"🆗", // 1F197 ; fully-qualified # 🆗 OK button +"🆘", // 1F198 ; fully-qualified # 🆘 SOS button +"🆙", // 1F199 ; fully-qualified # 🆙 UP! button +"🆚", // 1F19A ; fully-qualified # 🆚 VS button +"🈁", // 1F201 ; fully-qualified # 🈁 Japanese “here” button +"🈶", // 1F236 ; fully-qualified # 🈶 Japanese “not free of charge” button +"🈯", // 1F22F ; fully-qualified # 🈯 Japanese “reserved” button +"🉐", // 1F250 ; fully-qualified # 🉐 Japanese “bargain” button +"🈹", // 1F239 ; fully-qualified # 🈹 Japanese “discount” button +"🈚", // 1F21A ; fully-qualified # 🈚 Japanese “free of charge” button +"🈲", // 1F232 ; fully-qualified # 🈲 Japanese “prohibited” button +"🉑", // 1F251 ; fully-qualified # 🉑 Japanese “acceptable” button +"🈸", // 1F238 ; fully-qualified # 🈸 Japanese “application” button +"🈴", // 1F234 ; fully-qualified # 🈴 Japanese “passing grade” button +"🈳", // 1F233 ; fully-qualified # 🈳 Japanese “vacancy” button +"🈺", // 1F23A ; fully-qualified # 🈺 Japanese “open for business” button +"🈵", // 1F235 ; fully-qualified # 🈵 Japanese “no vacancy” button +"🔴", // 1F534 ; fully-qualified # 🔴 red circle +"🟠", // 1F7E0 ; fully-qualified # 🟠 orange circle +"🟡", // 1F7E1 ; fully-qualified # 🟡 yellow circle +"🟢", // 1F7E2 ; fully-qualified # 🟢 green circle +"🔵", // 1F535 ; fully-qualified # 🔵 blue circle +"🟣", // 1F7E3 ; fully-qualified # 🟣 purple circle +"🟤", // 1F7E4 ; fully-qualified # 🟤 brown circle +"⚫", // 26AB ; fully-qualified # ⚫ black circle +"⚪", // 26AA ; fully-qualified # ⚪ white circle +"🟥", // 1F7E5 ; fully-qualified # 🟥 red square +"🟧", // 1F7E7 ; fully-qualified # 🟧 orange square +"🟨", // 1F7E8 ; fully-qualified # 🟨 yellow square +"🟩", // 1F7E9 ; fully-qualified # 🟩 green square +"🟦", // 1F7E6 ; fully-qualified # 🟦 blue square +"🟪", // 1F7EA ; fully-qualified # 🟪 purple square +"🟫", // 1F7EB ; fully-qualified # 🟫 brown square +"⬛", // 2B1B ; fully-qualified # ⬛ black large square +"⬜", // 2B1C ; fully-qualified # ⬜ white large square +"◾", // 25FE ; fully-qualified # ◾ black medium-small square +"◽", // 25FD ; fully-qualified # ◽ white medium-small square +"🔶", // 1F536 ; fully-qualified # 🔶 large orange diamond +"🔷", // 1F537 ; fully-qualified # 🔷 large blue diamond +"🔸", // 1F538 ; fully-qualified # 🔸 small orange diamond +"🔹", // 1F539 ; fully-qualified # 🔹 small blue diamond +"🔺", // 1F53A ; fully-qualified # 🔺 red triangle pointed up +"🔻", // 1F53B ; fully-qualified # 🔻 red triangle pointed down +"💠", // 1F4A0 ; fully-qualified # 💠 diamond with a dot +"🔘", // 1F518 ; fully-qualified # 🔘 radio button +"🔳", // 1F533 ; fully-qualified # 🔳 white square button +"🔲", // 1F532 ; fully-qualified # 🔲 black square button +}, +{ // # group: Flags +"🏁", // 1F3C1 ; fully-qualified # 🏁 chequered flag +"🚩", // 1F6A9 ; fully-qualified # 🚩 triangular flag +"🎌", // 1F38C ; fully-qualified # 🎌 crossed flags +"🏴", // 1F3F4 ; fully-qualified # 🏴 black flag +} + }; +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt new file mode 100644 index 0000000..6f2542b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt @@ -0,0 +1,157 @@ +/* Copyright 2018 Jochem Raat + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.graphics.Matrix + +import com.keylesspalace.tusky.entity.Attachment.Focus + +/** + * Calculates the image matrix needed to maintain the correct cropping for image views based on + * their focal point. + * + * The purpose of this class is to make sure that the focal point information on media + * attachments are honoured. This class uses the custom matrix option of android ImageView's to + * customize how the image is cropped into the view. + * + * See the explanation of focal points here: + * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point + */ +object FocalPointUtil { + /** + * Update the given matrix for the given parameters. + * + * How it works is using the following steps: + * - First we determine if the image is too wide or too tall for the view size. If it is + * too wide, we need to crop it horizontally and scale the height to fit the view + * exactly. If it is too tall we need to crop vertically and scale the width to fit the + * view exactly. + * - Then we determine what translation is needed to get the focal point in view. We + * prefer to get the focal point at the center of the preview. However if that would + * result in some part of the preview being empty, we instead align the image so that it + * fills the view, but still the focal point is always in view. + * + * @param viewWidth The width of the imageView. + * @param viewHeight The height of the imageView + * @param imageWidth The width of the actual image + * @param imageHeight The height of the actual image + * @param focus The focal point to focus + * @param mat The matrix to update, this matrix is reset() and then updated with the new + * configuration. We reuse the old matrix to prevent unnecessary allocations. + * + * @return The matrix which correctly crops the image + */ + fun updateFocalPointMatrix(viewWidth: Float, + viewHeight: Float, + imageWidth: Float, + imageHeight: Float, + focus: Focus, + mat: Matrix) { + // Reset the cached matrix: + mat.reset() + + // calculate scaling: + val scale = calculateScaling(viewWidth, viewHeight, imageWidth, imageHeight) + mat.preScale(scale, scale) + + // calculate offsets: + var top = 0f + var left = 0f + if (isVerticalCrop(viewWidth, viewHeight, imageWidth, imageHeight)) { + top = focalOffset(viewHeight, imageHeight, scale, focalYToCoordinate(focus.y)) + } else { // horizontal crop + left = focalOffset(viewWidth, imageWidth, scale, focalXToCoordinate(focus.x)) + } + + mat.postTranslate(left, top) + } + + /** + * Calculate the scaling of the image needed to make it fill the screen. + * + * The scaling used depends on if we need a vertical of horizontal crop. + */ + fun calculateScaling(viewWidth: Float, viewHeight: Float, + imageWidth: Float, imageHeight: Float): Float { + return if (isVerticalCrop(viewWidth, viewHeight, imageWidth, imageHeight)) { + viewWidth / imageWidth + } else { // horizontal crop: + viewHeight / imageHeight + } + } + + /** + * Return true if we need a vertical crop, false for a horizontal crop. + */ + fun isVerticalCrop(viewWidth: Float, viewHeight: Float, + imageWidth: Float, imageHeight: Float): Boolean { + val viewRatio = viewWidth / viewHeight + val imageRatio = imageWidth / imageHeight + + return viewRatio > imageRatio + } + + /** + * Transform the focal x component to the corresponding coordinate on the image. + * + * This means that we go from a representation where the left side of the image is -1 and + * the right side +1, to a representation with the left side being 0 and the right side + * being +1. + */ + fun focalXToCoordinate(x: Float): Float { + return (x + 1) / 2 + } + + /** + * Transform the focal y component to the corresponding coordinate on the image. + * + * This means that we go from a representation where the bottom side of the image is -1 and + * the top side +1, to a representation with the top side being 0 and the bottom side + * being +1. + */ + fun focalYToCoordinate(y: Float): Float { + return (-y + 1) / 2 + } + + /** + * Calculate the relative offset needed to focus on the focal point in one direction. + * + * This method works for both the vertical and horizontal crops. It simply calculates + * what offset to take based on the proportions between the scaled image and the view + * available. It also makes sure to always fill the bounds of the view completely with + * the image. So it won't put the very edge of the image in center, because that would + * leave part of the view empty. + */ + fun focalOffset(view: Float, image: Float, + scale: Float, focal: Float): Float { + // The fraction of the image that will be in view: + val inView = view / (scale * image) + var offset = 0f + + // These values indicate the maximum and minimum focal parameter possible while still + // keeping the entire view filled with the image: + val maxFocal = 1 - inView / 2 + val minFocal = inView / 2 + + if (focal > maxFocal) { + offset = -((2 - inView) / 2) * image * scale + view * 0.5f + } else if (focal > minFocal) { + offset = -focal * image * scale + view * 0.5f + } + + return offset + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/HTMLEdit.java b/app/src/main/java/com/keylesspalace/tusky/util/HTMLEdit.java new file mode 100644 index 0000000..bb34425 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/HTMLEdit.java @@ -0,0 +1,104 @@ +package com.keylesspalace.tusky.util; + +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import android.text.Editable; +import android.text.Selection; +import android.text.Spannable; +import android.widget.EditText; +import me.thanel.markdownedit.SelectionUtils; + +public class HTMLEdit { + private HTMLEdit() { /* cannot be instantiated */ } + + public static void addBold(@NonNull Editable text) { + surroundSelectionWith(text, "", ""); + } + + public static void addBold(@NonNull EditText editText) { + addBold(editText.getText()); + } + + public static void addItalic(@NonNull Editable text) { + surroundSelectionWith(text, "", ""); + } + + public static void addItalic(@NonNull EditText editText) { + addItalic(editText.getText()); + } + + public static void addStrikeThrough(@NonNull Editable text) { + surroundSelectionWith(text, "", ""); + } + + public static void addStrikeThrough(@NonNull EditText editText) { + addStrikeThrough(editText.getText()); + } + + public static void addLink(@NonNull Editable text) { + if (!SelectionUtils.hasSelection(text)) { + SelectionUtils.selectWordAroundCursor(text); + } + String selectedText = SelectionUtils.getSelectedText(text).toString().trim(); + + int selectionStart = SelectionUtils.getSelectionStart(text); + + String begin = ""; + String end = ""; + String result = begin + selectedText + end; + SelectionUtils.replaceSelectedText(text, result); + + if (selectedText.length() == 0) { + Selection.setSelection(text, selectionStart + begin.length()); + } else { + selectionStart = selectionStart + 9; // ", ""); + } + + /** + * Inserts a markdown code block to the specified EditText at the currently selected position. + * + * @param editText The {@link EditText} view to which to add markdown code block. + */ + public static void addCode(@NonNull EditText editText) { + addCode(editText.getText()); + } + + public static void surroundSelectionWith(@NonNull Editable text, @NonNull String surroundText, @NonNull String surroundText2) { + if (!SelectionUtils.hasSelection(text)) { + SelectionUtils.selectWordAroundCursor(text); + } + CharSequence selectedText = SelectionUtils.getSelectedText(text); + int selectionStart = SelectionUtils.getSelectionStart(text); + + selectedText = selectedText.toString().trim(); + + StringBuilder result = new StringBuilder(); + result.append(surroundText).append(selectedText).append(surroundText2); + + int charactersToGoBack = 0; + if (selectedText.length() == 0) { + charactersToGoBack = surroundText2.length(); + } + + SelectionUtils.replaceSelectedText(text, result); + Selection.setSelection(text, selectionStart + result.length() - charactersToGoBack); + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java new file mode 100644 index 0000000..27f3dff --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java @@ -0,0 +1,162 @@ +/* Written in 2017 by Andrew Dawson + * + * To the extent possible under law, the author(s) have dedicated all copyright and related and + * neighboring rights to this software to the public domain worldwide. This software is distributed + * without any warranty. + * + * You should have received a copy of the CC0 Public Domain Dedication along with this software. + * If not, see . */ + +package com.keylesspalace.tusky.util; + +import android.net.Uri; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents one link and its parameters from the link header of an HTTP message. + * + * @see RFC5988 + */ +public class HttpHeaderLink { + private static class Parameter { + public String name; + public String value; + } + + private List parameters; + public Uri uri; + + private HttpHeaderLink(String uri) { + this.uri = Uri.parse(uri); + this.parameters = new ArrayList<>(); + } + + private static int findAny(String s, int fromIndex, char[] set) { + for (int i = fromIndex; i < s.length(); i++) { + char c = s.charAt(i); + for (char member : set) { + if (c == member) { + return i; + } + } + } + return -1; + } + + private static int findEndOfQuotedString(String line, int start) { + for (int i = start; i < line.length(); i++) { + char c = line.charAt(i); + if (c == '\\') { + i += 1; + } else if (c == '"') { + return i; + } + } + return -1; + } + + private static class ValueResult { + String value; + int end; + + ValueResult() { + end = -1; + } + + void setValue(String value) { + value = value.trim(); + if (!value.isEmpty()) { + this.value = value; + } + } + } + + private static ValueResult parseValue(String line, int start) { + ValueResult result = new ValueResult(); + int foundIndex = findAny(line, start, new char[] {';', ',', '"'}); + if (foundIndex == -1) { + result.setValue(line.substring(start)); + return result; + } + char c = line.charAt(foundIndex); + if (c == ';' || c == ',') { + result.end = foundIndex; + result.setValue(line.substring(start, foundIndex)); + return result; + } else { + int quoteEnd = findEndOfQuotedString(line, foundIndex + 1); + if (quoteEnd == -1) { + quoteEnd = line.length(); + } + result.end = quoteEnd; + result.setValue(line.substring(foundIndex + 1, quoteEnd)); + return result; + } + } + + private static int parseParameters(String line, int start, HttpHeaderLink link) { + for (int i = start; i < line.length(); i++) { + int foundIndex = findAny(line, i, new char[] {'=', ','}); + if (foundIndex == -1) { + return -1; + } else if (line.charAt(foundIndex) == ',') { + return foundIndex; + } + Parameter parameter = new Parameter(); + parameter.name = line.substring(line.indexOf(';', i) + 1, foundIndex).trim(); + link.parameters.add(parameter); + ValueResult result = parseValue(line, foundIndex); + parameter.value = result.value; + if (result.end == -1) { + return -1; + } else { + i = result.end; + } + } + return -1; + } + + /** + * @param line the entire link header, not including the initial "Link:" + * @return all links found in the header + */ + public static List parse(@Nullable String line) { + List linkList = new ArrayList<>(); + if (line != null) { + for (int i = 0; i < line.length(); i++) { + int uriEnd = line.indexOf('>', i); + String uri = line.substring(line.indexOf('<', i) + 1, uriEnd); + HttpHeaderLink link = new HttpHeaderLink(uri); + linkList.add(link); + int parseEnd = parseParameters(line, uriEnd, link); + if (parseEnd == -1) { + break; + } else { + i = parseEnd; + } + } + } + return linkList; + } + + /** + * @param links intended to be those returned by parse() + * @param relationType of the parameter "rel", commonly "next" or "prev" + * @return the link matching the given relation type + */ + @Nullable + public static HttpHeaderLink findByRelationType(List links, + String relationType) { + for (HttpHeaderLink link : links) { + for (Parameter parameter : link.parameters) { + if (parameter.name.equals("rel") && parameter.value.equals(relationType)) { + return link; + } + } + } + return null; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java new file mode 100644 index 0000000..7c3b68a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java @@ -0,0 +1,71 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util; + +import android.content.ContentResolver; +import android.net.Uri; +import androidx.annotation.Nullable; + +import java.io.Closeable; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +public class IOUtils { + + private static final int DEFAULT_BLOCKSIZE = 16384; + + public static void closeQuietly(@Nullable Closeable stream) { + try { + if (stream != null) { + stream.close(); + } + } catch (IOException e) { + // intentionally unhandled + } + } + + public static boolean copyToFile(ContentResolver contentResolver, Uri uri, File file) { + InputStream from; + FileOutputStream to; + try { + from = contentResolver.openInputStream(uri); + to = new FileOutputStream(file); + } catch (FileNotFoundException e) { + return false; + } + if (from == null) { + return false; + } + byte[] chunk = new byte[DEFAULT_BLOCKSIZE]; + try { + while (true) { + int bytes = from.read(chunk, 0, chunk.length); + if (bytes < 0) { + break; + } + to.write(chunk, 0, bytes); + } + } catch (IOException e) { + return false; + } + closeQuietly(from); + closeQuietly(to); + return true; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt new file mode 100644 index 0000000..9daf16f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -0,0 +1,51 @@ +@file:JvmName("ImageLoadingHelper") + +package com.keylesspalace.tusky.util + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.widget.ImageView +import androidx.annotation.Px +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.keylesspalace.tusky.R + + +private val centerCropTransformation = CenterCrop() + +fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { + + if (url.isNullOrBlank()) { + Glide.with(imageView) + .load(R.drawable.avatar_default) + .into(imageView) + } else { + if (animate) { + Glide.with(imageView) + .load(url) + .transform( + centerCropTransformation, + RoundedCorners(radius) + ) + .placeholder(R.drawable.avatar_default) + .into(imageView) + + } else { + Glide.with(imageView) + .asBitmap() + .load(url) + .transform( + centerCropTransformation, + RoundedCorners(radius) + ) + .placeholder(R.drawable.avatar_default) + .into(imageView) + } + + } +} + +fun decodeBlurHash(context: Context, blurhash: String): BitmapDrawable { + return BitmapDrawable(context.resources, BlurHashDecoder.decode(blurhash, 32, 32, 1f)) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java new file mode 100644 index 0000000..c05f224 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -0,0 +1,265 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabColorSchemeParams; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.preference.PreferenceManager; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.LinkListener; + +import java.net.URI; +import java.net.URISyntaxException; + +public class LinkHelper { + public static String getDomain(String urlString) { + // sometimes URL can be null due to Pleroma bug + if(urlString == null) + return ""; + + URI uri; + try { + uri = new URI(urlString); + } catch (URISyntaxException e) { + return ""; + } + String host = uri.getHost(); + if(host == null) { + return ""; + } else if (host.startsWith("www.")) { + return host.substring(4); + } else { + return host; + } + } + + /** + * Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating + * them with callbacks to notify when they're clicked. + * + * @param view the returned text will be put in + * @param content containing text with mentions, links, or hashtags + * @param mentions any '@' mentions which are known to be in the content + * @param listener to notify about particular spans that are clicked + */ + public static void setClickableText(TextView view, CharSequence content, + @Nullable Status.Mention[] mentions, final LinkListener listener) { + SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content); + URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class); + for (URLSpan span : urlSpans) { + int start = builder.getSpanStart(span); + int end = builder.getSpanEnd(span); + int flags = builder.getSpanFlags(span); + CharSequence text = builder.subSequence(start, end); + ClickableSpan customSpan = null; + + if (text.charAt(0) == '#') { + final String tag = text.subSequence(1, text.length()).toString(); + customSpan = new ClickableSpanNoUnderline() { + @Override + public void onClick(@NonNull View widget) { listener.onViewTag(tag); } + }; + } else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) { + String accountUsername = text.subSequence(1, text.length()).toString(); + /* There may be multiple matches for users on different instances with the same + * username. If a match has the same domain we know it's for sure the same, but if + * that can't be found then just go with whichever one matched last. */ + String id = null; + for (Status.Mention mention : mentions) { + if (mention.getLocalUsername().equalsIgnoreCase(accountUsername)) { + id = mention.getId(); + String url = mention.getUrl(); + if (url != null && url.contains(getDomain(span.getURL()))) { + break; + } + } + } + if (id != null) { + final String accountId = id; + customSpan = new ClickableSpanNoUnderline() { + @Override + public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); } + }; + } + } + + if (customSpan == null) { + customSpan = new CustomURLSpan(span.getURL()) { + @Override + public void onClick(View widget) { + listener.onViewUrl(getURL()); + } + }; + } + builder.removeSpan(span); + builder.setSpan(customSpan, start, end, flags); + + /* Add zero-width space after links in end of line to fix its too large hitbox. + * See also : https://github.com/tuskyapp/Tusky/issues/846 + * https://github.com/tuskyapp/Tusky/pull/916 */ + if (end >= builder.length() || + builder.subSequence(end, end + 1).toString().equals("\n")){ + builder.insert(end, "\u200B"); + } + } + + view.setText(builder); + view.setMovementMethod(LinkMovementMethod.getInstance()); + } + + /** + * Put mentions in a piece of text and makes them clickable, associating them with callbacks to + * notify when they're clicked. + * + * @param view the returned text will be put in + * @param mentions any '@' mentions which are known to be in the content + * @param listener to notify about particular spans that are clicked + */ + public static void setClickableMentions( + TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) { + if (mentions == null || mentions.length == 0) { + view.setText(null); + return; + } + SpannableStringBuilder builder = new SpannableStringBuilder(); + int start = 0; + int end = 0; + int flags; + boolean firstMention = true; + for (Status.Mention mention : mentions) { + String accountUsername = mention.getLocalUsername(); + final String accountId = mention.getId(); + ClickableSpan customSpan = new ClickableSpanNoUnderline() { + @Override + public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); } + }; + + end += 1 + accountUsername.length(); // length of @ + username + flags = builder.getSpanFlags(customSpan); + if (firstMention) { + firstMention = false; + } else { + builder.append(" "); + start += 1; + end += 1; + } + builder.append("@"); + builder.append(accountUsername); + builder.setSpan(customSpan, start, end, flags); + builder.append("\u200B"); // same reasonning than in setClickableText + end += 1; // shift position to take the previous character into account + start = end; + } + view.setText(builder); + view.setMovementMethod(LinkMovementMethod.getInstance()); + } + + public static CharSequence createClickableText(String text, String link) { + URLSpan span = new CustomURLSpan(link); + + SpannableString clickableText = new SpannableString(text); + clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return clickableText; + } + + /** + * Opens a link, depending on the settings, either in the browser or in a custom tab + * + * @param url a string containing the url to open + * @param context context + */ + public static void openLink(String url, Context context) { + if(url == null) + return; + + Uri uri = Uri.parse(url).normalizeScheme(); + + boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("customTabs", false); + if (useCustomTabs) { + openLinkInCustomTab(uri, context); + } else { + openLinkInBrowser(uri, context); + } + } + + /** + * opens a link in the browser via Intent.ACTION_VIEW + * + * @param uri the uri to open + * @param context context + */ + public static void openLinkInBrowser(Uri uri, Context context) { + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.w("LinkHelper", "Actvity was not found for intent, " + intent); + } + } + + /** + * tries to open a link in a custom tab + * falls back to browser if not possible + * + * @param uri the uri to open + * @param context context + */ + public static void openLinkInCustomTab(Uri uri, Context context) { + int toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface); + int navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor); + int navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor); + + CustomTabColorSchemeParams colorSchemeParams = new CustomTabColorSchemeParams.Builder() + .setToolbarColor(toolbarColor) + .setNavigationBarColor(navigationbarColor) + .setNavigationBarDividerColor(navigationbarDividerColor) + .build(); + + CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() + .setDefaultColorSchemeParams(colorSchemeParams) + .setShowTitle(true) + .build(); + + try { + customTabsIntent.launchUrl(context, uri); + } catch (ActivityNotFoundException e) { + Log.w("LinkHelper", "Activity was not found for intent " + customTabsIntent); + openLinkInBrowser(uri, context); + } + + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt new file mode 100644 index 0000000..8594dfc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -0,0 +1,325 @@ +package com.keylesspalace.tusky.util + +import android.content.Context +import android.os.Bundle +import android.text.Spannable +import android.text.style.URLSpan +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.entity.Status.Companion.MAX_MEDIA_ATTACHMENTS +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlin.math.min + +// Not using lambdas because there's boxing of int then +interface StatusProvider { + fun getStatus(pos: Int): StatusViewData? +} + +class ListStatusAccessibilityDelegate( + private val recyclerView: RecyclerView, + private val statusActionListener: StatusActionListener, + private val statusProvider: StatusProvider +) : RecyclerViewAccessibilityDelegate(recyclerView) { + private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) + as AccessibilityManager + + override fun getItemDelegate(): AccessibilityDelegateCompat = itemDelegate + + private val context: Context get() = recyclerView.context + + private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) { + override fun onInitializeAccessibilityNodeInfo(host: View, + info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + + val pos = recyclerView.getChildAdapterPosition(host) + val status = statusProvider.getStatus(pos) ?: return + if (status is StatusViewData.Concrete) { + if (!status.spoilerText.isNullOrEmpty()) { + info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) + } + + info.addAction(replyAction) + + if (status.rebloggingEnabled) { + info.addAction(if (status.isReblogged) unreblogAction else reblogAction) + } + info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction) + info.addAction(if (status.isBookmarked) unbookmarkAction else bookmarkAction) + + val mediaActions = intArrayOf( + R.id.action_open_media_1, + R.id.action_open_media_2, + R.id.action_open_media_3, + R.id.action_open_media_4) + val attachmentCount = min(status.attachments.size, MAX_MEDIA_ATTACHMENTS) + for (i in 0 until attachmentCount) { + info.addAction(AccessibilityActionCompat( + mediaActions[i], + context.getString(R.string.action_open_media_n, i + 1))) + } + + info.addAction(openProfileAction) + if (getLinks(status).any()) info.addAction(linksAction) + + val mentions = status.mentions + if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction) + + if (getHashtags(status).any()) info.addAction(hashtagsAction) + if (!status.rebloggedByUsername.isNullOrEmpty()) { + info.addAction(openRebloggerAction) + } + if (status.reblogsCount > 0) info.addAction(openRebloggedByAction) + if (status.favouritesCount > 0) info.addAction(openFavsAction) + + info.addAction(moreAction) + } + + } + + override fun performAccessibilityAction(host: View, action: Int, + args: Bundle?): Boolean { + val pos = recyclerView.getChildAdapterPosition(host) + when (action) { + R.id.action_reply -> { + interrupt() + statusActionListener.onReply(pos) + } + R.id.action_favourite -> statusActionListener.onFavourite(true, pos) + R.id.action_unfavourite -> statusActionListener.onFavourite(false, pos) + R.id.action_bookmark -> statusActionListener.onBookmark(true, pos) + R.id.action_unbookmark -> statusActionListener.onBookmark(false, pos) + R.id.action_reblog -> statusActionListener.onReblog(true, pos) + R.id.action_unreblog -> statusActionListener.onReblog(false, pos) + R.id.action_open_profile -> { + interrupt() + statusActionListener.onViewAccount( + (statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId) + } + R.id.action_open_media_1 -> { + interrupt() + statusActionListener.onViewMedia(pos, 0, null) + } + R.id.action_open_media_2 -> { + interrupt() + statusActionListener.onViewMedia(pos, 1, null) + } + R.id.action_open_media_3 -> { + interrupt() + statusActionListener.onViewMedia(pos, 2, null) + } + R.id.action_open_media_4 -> { + interrupt() + statusActionListener.onViewMedia(pos, 3, null) + } + R.id.action_expand_cw -> { + // Toggling it directly to avoid animations + // which cannot be disabled for detaild status for some reason + val holder = recyclerView.getChildViewHolder(host) as StatusBaseViewHolder + holder.toggleContentWarning() + // Stop and restart narrator before it reads old description. + // Would be nice if we could *just* read the content here but doesn't seem + // to be possible. + forceFocus(host) + } + R.id.action_collapse_cw -> { + statusActionListener.onExpandedChange(false, pos) + interrupt() + } + R.id.action_links -> showLinksDialog(host) + R.id.action_mentions -> showMentionsDialog(host) + R.id.action_hashtags -> showHashtagsDialog(host) + R.id.action_open_reblogger -> { + interrupt() + statusActionListener.onOpenReblog(pos) + } + R.id.action_open_reblogged_by -> { + interrupt() + statusActionListener.onShowReblogs(pos) + } + R.id.action_open_faved_by -> { + interrupt() + statusActionListener.onShowFavs(pos) + } + R.id.action_more -> { + statusActionListener.onMore(host, pos) + } + else -> return super.performAccessibilityAction(host, action, args) + } + return true + } + + + private fun showLinksDialog(host: View) { + val status = getStatus(host) as? StatusViewData.Concrete ?: return + val links = getLinks(status).toList() + val textLinks = links.map { item -> item.link } + AlertDialog.Builder(host.context) + .setTitle(R.string.title_links_dialog) + .setAdapter(ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, + textLinks) + ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } + .show() + .let { forceFocus(it.listView) } + } + + private fun showMentionsDialog(host: View) { + val status = getStatus(host) as? StatusViewData.Concrete ?: return + val mentions = status.mentions ?: return + val stringMentions = mentions.map { it.username } + AlertDialog.Builder(host.context) + .setTitle(R.string.title_mentions_dialog) + .setAdapter(ArrayAdapter(host.context, + android.R.layout.simple_list_item_1, stringMentions) + ) { _, which -> + statusActionListener.onViewAccount(mentions[which].id) + } + .show() + .let { forceFocus(it.listView) } + } + + private fun showHashtagsDialog(host: View) { + val status = getStatus(host) as? StatusViewData.Concrete ?: return + val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() + AlertDialog.Builder(host.context) + .setTitle(R.string.title_hashtags_dialog) + .setAdapter(ArrayAdapter(host.context, + android.R.layout.simple_list_item_1, tags) + ) { _, which -> + statusActionListener.onViewTag(tags[which].toString()) + } + .show() + .let { forceFocus(it.listView) } + } + + private fun getStatus(childView: View): StatusViewData { + return statusProvider.getStatus(recyclerView.getChildAdapterPosition(childView))!! + } + } + + + private fun getLinks(status: StatusViewData.Concrete): Sequence { + val content = status.content + return if (content is Spannable) { + content.getSpans(0, content.length, URLSpan::class.java) + .asSequence() + .map { span -> + val text = content.subSequence( + content.getSpanStart(span), + content.getSpanEnd(span)) + if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url) + } + .filterNotNull() + } else { + emptySequence() + } + } + + private fun getHashtags(status: StatusViewData.Concrete): Sequence { + val content = status.content + return content.getSpans(0, content.length, Object::class.java) + .asSequence() + .map { span -> + content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)) + } + .filter(this::isHashtag) + } + + private fun forceFocus(host: View) { + interrupt() + host.post { + host.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) + } + } + + private fun interrupt() { + a11yManager.interrupt() + } + + + private fun isHashtag(text: CharSequence) = text.startsWith("#") + + private val collapseCwAction = AccessibilityActionCompat( + R.id.action_collapse_cw, + context.getString(R.string.status_content_warning_show_less)) + + private val expandCwAction = AccessibilityActionCompat( + R.id.action_expand_cw, + context.getString(R.string.status_content_warning_show_more)) + + private val replyAction = AccessibilityActionCompat( + R.id.action_reply, + context.getString(R.string.action_reply)) + + private val unreblogAction = AccessibilityActionCompat( + R.id.action_unreblog, + context.getString(R.string.action_unreblog)) + + private val reblogAction = AccessibilityActionCompat( + R.id.action_reblog, + context.getString(R.string.action_reblog)) + + private val unfavouriteAction = AccessibilityActionCompat( + R.id.action_unfavourite, + context.getString(R.string.action_unfavourite)) + + private val favouriteAction = AccessibilityActionCompat( + R.id.action_favourite, + context.getString(R.string.action_favourite)) + + private val bookmarkAction = AccessibilityActionCompat( + R.id.action_bookmark, + context.getString(R.string.action_bookmark)) + + private val unbookmarkAction = AccessibilityActionCompat( + R.id.action_unbookmark, + context.getString(R.string.action_bookmark)) + + private val openProfileAction = AccessibilityActionCompat( + R.id.action_open_profile, + context.getString(R.string.action_view_profile)) + + private val linksAction = AccessibilityActionCompat( + R.id.action_links, + context.getString(R.string.action_links)) + + private val mentionsAction = AccessibilityActionCompat( + R.id.action_mentions, + context.getString(R.string.action_mentions)) + + private val hashtagsAction = AccessibilityActionCompat( + R.id.action_hashtags, + context.getString(R.string.action_hashtags)) + + private val openRebloggerAction = AccessibilityActionCompat( + R.id.action_open_reblogger, + context.getString(R.string.action_open_reblogger)) + + private val openRebloggedByAction = AccessibilityActionCompat( + R.id.action_open_reblogged_by, + context.getString(R.string.action_open_reblogged_by)) + + private val openFavsAction = AccessibilityActionCompat( + R.id.action_open_faved_by, + context.getString(R.string.action_open_faved_by)) + + private val moreAction = AccessibilityActionCompat( + R.id.action_more, + context.getString(R.string.action_more) + ) + + private data class LinkSpanInfo(val text: String, val link: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt new file mode 100644 index 0000000..8a5223c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -0,0 +1,55 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +@file:JvmName("ListUtils") + +package com.keylesspalace.tusky.util + +import java.util.LinkedHashSet +import java.util.ArrayList + + +/** + * @return true if list is null or else return list.isEmpty() + */ +fun isEmpty(list: List<*>?): Boolean { + return list == null || list.isEmpty() +} + +/** + * @return a new ArrayList containing the elements without duplicates in the same order + */ +fun removeDuplicates(list: List): ArrayList { + val set = LinkedHashSet(list) + return ArrayList(set) +} + +inline fun List.withoutFirstWhich(predicate: (T) -> Boolean): List { + val newList = toMutableList() + val index = newList.indexOfFirst(predicate) + if (index != -1) { + newList.removeAt(index) + } + return newList +} + +inline fun List.replacedFirstWhich(replacement: T, predicate: (T) -> Boolean): List { + val newList = toMutableList() + val index = newList.indexOfFirst(predicate) + if (index != -1) { + newList[index] = replacement + } + return newList +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt b/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt new file mode 100644 index 0000000..3d4234c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 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 com.keylesspalace.tusky.util + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList + +/** + * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system + */ +data class Listing( + // the LiveData of paged lists for the UI to observe + val pagedList: LiveData>, + // represents the network request status to show to the user + val networkState: LiveData, + // represents the refresh status to show to the user. Separate from networkState, this + // value is importantly only when refresh is requested. + val refreshState: LiveData, + // refreshes the whole data and fetches it from scratch. + val refresh: () -> Unit, + // retries any failed requests. + val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt new file mode 100644 index 0000000..867a5a8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt @@ -0,0 +1,111 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import androidx.lifecycle.* +import io.reactivex.BackpressureStrategy +import io.reactivex.Observable +import io.reactivex.Single + +inline fun LiveData.map(crossinline mapFunction: (X) -> Y): LiveData = + Transformations.map(this) { input -> mapFunction(input) } + +inline fun LiveData.switchMap( + crossinline switchMapFunction: (X) -> LiveData +): LiveData = Transformations.switchMap(this) { input -> switchMapFunction(input) } + +fun LiveData.observeOnce(observer: (T) -> Unit) { + observeForever(object: Observer { + override fun onChanged(value: T) { + removeObserver(this) + observer(value) + } + }) +} + +fun LiveData.observeOnce(owner: LifecycleOwner, observer: (T) -> Unit) { + observe(owner, object: Observer { + override fun onChanged(value: T) { + removeObserver(this) + observer(value) + } + }) +} + +inline fun LiveData.filter(crossinline predicate: (X) -> Boolean): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(this) { value -> + if (predicate(value)) { + liveData.value = value + } + } + return liveData +} + +fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) = + LifecycleContext(this).apply(body) + +class LifecycleContext(val lifecycleOwner: LifecycleOwner) { + inline fun LiveData.observe(crossinline observer: (T) -> Unit) = + this.observe(lifecycleOwner, Observer { observer(it) }) + + /** + * Just hold a subscription, + */ + fun LiveData.subscribe() = + this.observe(lifecycleOwner, Observer { }) +} + +/** + * Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns + * [LiveData] with value set to the result of calling [combiner] with value of both. + * Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked. + */ +fun combineLiveData(a: LiveData, b: LiveData, combiner: (A, B) -> R): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(a) { + if (a.value != null && b.value != null) { + liveData.value = combiner(a.value!!, b.value!!) + } + } + liveData.addSource(b) { + if (a.value != null && b.value != null) { + liveData.value = combiner(a.value!!, b.value!!) + } + } + return liveData +} + +/** + * Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b] + * after either changes. Doesn't check if either has value. + * Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked. + */ +fun combineOptionalLiveData(a: LiveData, b: LiveData, combiner: (A?, B?) -> R): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(a) { + liveData.value = combiner(a.value, b.value) + } + liveData.addSource(b) { + liveData.value = combiner(a.value, b.value) + } + return liveData +} + +fun Single.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable()) +fun Observable.toLiveData( + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST +) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST)) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt new file mode 100644 index 0000000..4a80bca --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -0,0 +1,41 @@ +/* Copyright 2019 Mélanie Chauvel (ariasuni) + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import androidx.preference.PreferenceManager +import java.util.* + +class LocaleManager(context: Context) { + + private var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + + fun setLocale(context: Context): Context { + val language = prefs.getNonNullString("language", "default") + if (language == "default") { + return context + } + val locale = Locale.forLanguageTag(language) + Locale.setDefault(locale) + + val res = context.resources + val config = Configuration(res.configuration) + config.setLocale(locale) + return context.createConfigurationContext(config) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt new file mode 100644 index 0000000..3b1e0fd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -0,0 +1,230 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.content.ContentResolver +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.provider.OpenableColumns +import androidx.annotation.Px +import androidx.exifinterface.media.ExifInterface +import android.util.Log +import java.io.* + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * Helper methods for obtaining and resizing media files + */ +private const val TAG = "MediaUtils" +private const val MEDIA_TEMP_PREFIX = "Share_Media" +const val MEDIA_SIZE_UNKNOWN = -1L + +/** + * Fetches the size of the media represented by the given URI, assuming it is openable and + * the ContentResolver is able to resolve it. + * + * @return the size of the media in bytes or {@link MediaUtils#MEDIA_SIZE_UNKNOWN} + */ +fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long { + if(uri == null) { + return MEDIA_SIZE_UNKNOWN + } + + var mediaSize = MEDIA_SIZE_UNKNOWN + val cursor: Cursor? + try { + cursor = contentResolver.query(uri, null, null, null, null) + } catch (e: SecurityException) { + return MEDIA_SIZE_UNKNOWN + } + if (cursor != null) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + mediaSize = cursor.getLong(sizeIndex) + cursor.close() + } + return mediaSize +} + +fun getSampledBitmap(contentResolver: ContentResolver, uri: Uri, @Px reqWidth: Int, @Px reqHeight: Int): Bitmap? { + // First decode with inJustDecodeBounds=true to check dimensions + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + var stream: InputStream? + try { + stream = contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + Log.w(TAG, e) + return null + } + + BitmapFactory.decodeStream(stream, null, options) + + IOUtils.closeQuietly(stream) + + // Calculate inSampleSize + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) + + // Decode bitmap with inSampleSize set + options.inJustDecodeBounds = false + return try { + stream = contentResolver.openInputStream(uri) + val bitmap = BitmapFactory.decodeStream(stream, null, options) + val orientation = getImageOrientation(uri, contentResolver) + reorientBitmap(bitmap, orientation) + } catch (e: FileNotFoundException) { + Log.w(TAG, e) + null + } catch (e: OutOfMemoryError) { + Log.e(TAG, "OutOfMemoryError while trying to get sampled Bitmap", e) + null + } finally { + IOUtils.closeQuietly(stream) + } +} + +@Throws(FileNotFoundException::class) +fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { + val input = contentResolver.openInputStream(uri) + + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(input, null, options) + + IOUtils.closeQuietly(input) + + return (options.outWidth * options.outHeight).toLong() +} + +fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + + val halfHeight = height / 2 + val halfWidth = width / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while ((halfHeight / inSampleSize) > reqHeight && (halfWidth / inSampleSize) > reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} + +fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_NORMAL -> return bitmap + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1.0f, 1.0f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180.0f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180.0f) + matrix.postScale(-1.0f, 1.0f) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90.0f) + matrix.postScale(-1.0f, 1.0f) + } + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90.0f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90.0f) + matrix.postScale(-1.0f, 1.0f) + } + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90.0f) + else -> return bitmap + } + + if (bitmap == null) { + return null + } + + return try { + val result = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, + bitmap.height, matrix, true) + if (!bitmap.sameAs(result)) { + bitmap.recycle() + } + result + } catch (e: OutOfMemoryError) { + null + } +} + +fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int { + val inputStream: InputStream? + try { + inputStream = contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + Log.w(TAG, e) + return ExifInterface.ORIENTATION_UNDEFINED + } + if (inputStream == null) { + return ExifInterface.ORIENTATION_UNDEFINED + } + val exifInterface: ExifInterface + try { + exifInterface = ExifInterface(inputStream) + } catch (e: IOException) { + Log.w(TAG, e) + IOUtils.closeQuietly(inputStream) + return ExifInterface.ORIENTATION_UNDEFINED + } + val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + IOUtils.closeQuietly(inputStream) + return orientation +} + +fun deleteStaleCachedMedia(mediaDirectory: File?) { + if (mediaDirectory == null || !mediaDirectory.exists()) { + // Nothing to do + return + } + + val twentyfourHoursAgo = Calendar.getInstance() + twentyfourHoursAgo.add(Calendar.HOUR, -24) + val unixTime = twentyfourHoursAgo.timeInMillis + + val files = mediaDirectory.listFiles{ file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) } + if (files == null || files.isEmpty()) { + // Nothing to do + return + } + + for (file in files) { + try { + file.delete() + } catch (se: SecurityException) { + Log.e(TAG, "Error removing stale cached media") + } + } +} + +fun getTemporaryMediaFilename(extension: String): String { + return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$extension" +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt b/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt new file mode 100644 index 0000000..09a0033 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2017 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 com.keylesspalace.tusky.util + +enum class Status { + RUNNING, + SUCCESS, + FAILED +} + +@Suppress("DataClassPrivateConstructor") +data class NetworkState private constructor( + val status: Status, + val msg: String? = null) { + companion object { + val LOADED = NetworkState(Status.SUCCESS) + val LOADING = NetworkState(Status.RUNNING) + fun error(msg: String?) = NetworkState(Status.FAILED, msg) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt new file mode 100644 index 0000000..65c8f6c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt @@ -0,0 +1,45 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import com.keylesspalace.tusky.entity.Notification +import org.json.JSONArray + +/** + * Serialize to string array and deserialize notifications type + */ + +fun serialize(data: Set?): String { + val array = JSONArray() + data?.forEach { + array.put(it.presentation) + } + return array.toString() +} + +fun deserialize(data: String?): Set { + val ret = HashSet() + data?.let { + val array = JSONArray(data) + for (i in 0 until array.length()) { + val item = array.getString(i) + val type = Notification.Type.byString(item) + if (type != Notification.Type.UNKNOWN) + ret.add(type) + } + } + return ret +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/OkHttpUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/OkHttpUtils.java new file mode 100644 index 0000000..b4adae5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/OkHttpUtils.java @@ -0,0 +1,89 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is part of Tusky. + * + * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU + * Lesser General Public License as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Tusky 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 Lesser + * General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License along with Tusky. If + * not, see . */ + +package com.keylesspalace.tusky.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import com.keylesspalace.tusky.BuildConfig; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.concurrent.TimeUnit; + +import okhttp3.Cache; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.brotli.BrotliInterceptor; + +public class OkHttpUtils { + + @NonNull + public static OkHttpClient.Builder getCompatibleClientBuilder(@NonNull Context context) { + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + boolean httpProxyEnabled = preferences.getBoolean("httpProxyEnabled", false); + String httpServer = preferences.getString("httpProxyServer", ""); + int httpPort; + try { + httpPort = Integer.parseInt(preferences.getString("httpProxyPort", "-1")); + } catch (NumberFormatException e) { + // user has entered wrong port, fall back to no proxy + httpPort = -1; + } + + int cacheSize = 25*1024*1024; // 25 MiB + + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .addInterceptor(getUserAgentInterceptor()) + .addInterceptor(BrotliInterceptor.INSTANCE) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .cache(new Cache(context.getCacheDir(), cacheSize)); + + if (httpProxyEnabled && !httpServer.isEmpty() && (httpPort > 0) && (httpPort < 65535)) { + InetSocketAddress address = InetSocketAddress.createUnresolved(httpServer, httpPort); + builder.proxy(new Proxy(Proxy.Type.HTTP, address)); + } + + return builder; + } + + /** + * Add a custom User-Agent that contains Tusky & Android Version to all requests + * Example: + * User-Agent: Tusky/1.1.2 Android/5.0.2 + */ + @NonNull + private static Interceptor getUserAgentInterceptor() { + return chain -> { + Request originalRequest = chain.request(); + Request requestWithUserAgent = originalRequest.newBuilder() + .header("User-Agent", BuildConfig.APPLICATION_NAME + "/"+ BuildConfig.VERSION_NAME+" Android/"+Build.VERSION.RELEASE) + .build(); + return chain.proceed(requestWithUserAgent); + }; + } + +} + + diff --git a/app/src/main/java/com/keylesspalace/tusky/util/OmittedDomainFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/util/OmittedDomainFetcher.kt new file mode 100644 index 0000000..60a4e1e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/OmittedDomainFetcher.kt @@ -0,0 +1,58 @@ +package com.keylesspalace.tusky.util + +import android.content.Context +import android.util.Log +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.HttpUrlFetcher +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 com.bumptech.glide.load.model.stream.HttpGlideUrlLoader +import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.signature.ObjectKey +import com.keylesspalace.tusky.TuskyApplication +import com.keylesspalace.tusky.db.AccountManager +import java.io.File +import java.io.InputStream +import javax.inject.Inject + +@GlideModule +class OmittedDomainAppModule : AppGlideModule() { + @Inject + lateinit var accountManager : AccountManager + + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + (context.applicationContext as TuskyApplication).androidInjector.inject(this) + + registry.append(String::class.java, InputStream::class.java, OmittedDomainLoaderFactory(accountManager)) + } +} + +class OmittedDomainLoaderFactory(val accountManager: AccountManager) : ModelLoaderFactory { + override fun teardown() = Unit + + override fun build(factory: MultiModelLoaderFactory): ModelLoader = OmittedDomainLoader(accountManager) +} + +class OmittedDomainLoader(val accountManager: AccountManager) : ModelLoader { + override fun buildLoadData(model: String, width: Int, height: Int, options: Options): ModelLoader.LoadData? + { + val trueUrl = if(accountManager.activeAccount != null) + "https://" + accountManager.activeAccount!!.domain + model + else model + + val timeout = options.get(HttpGlideUrlLoader.TIMEOUT) ?: 100 + + return ModelLoader.LoadData(ObjectKey(model), HttpUrlFetcher(GlideUrl(trueUrl), timeout)) + } + + + override fun handles(model: String): Boolean { + val file = File(model) + return !file.exists() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java new file mode 100644 index 0000000..4f7d3ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java @@ -0,0 +1,491 @@ +/* + * Copyright 2017 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 com.keylesspalace.tusky.util; + +import androidx.annotation.AnyThread; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import java.util.Arrays; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +/** + * A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and + * {@link androidx.paging.DataSource}s to help with tracking network requests. + *

+ * It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL}, + * {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request + * for each of them via {@link #runIfNotRunning(RequestType, Request)}. + *

+ * It tracks a {@link Status} and an {@code error} for each {@link RequestType}. + *

+ * A sample usage of this class to limit requests looks like this: + *

+ * class PagingBoundaryCallback extends PagedList.BoundaryCallback<MyItem> {
+ *     // TODO replace with an executor from your application
+ *     Executor executor = Executors.newSingleThreadExecutor();
+ *     PagingRequestHelper helper = new PagingRequestHelper(executor);
+ *     // imaginary API service, using Retrofit
+ *     MyApi api;
+ *
+ *     {@literal @}Override
+ *     public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
+ *         helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE,
+ *                 helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
+ *                         new Callback<ApiResponse>() {
+ *                             {@literal @}Override
+ *                             public void onResponse(Call<ApiResponse> call,
+ *                                     Response<ApiResponse> response) {
+ *                                 // TODO insert new records into database
+ *                                 helperCallback.recordSuccess();
+ *                             }
+ *
+ *                             {@literal @}Override
+ *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
+ *                                 helperCallback.recordFailure(t);
+ *                             }
+ *                         }));
+ *     }
+ *
+ *     {@literal @}Override
+ *     public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
+ *         helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER,
+ *                 helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
+ *                         new Callback<ApiResponse>() {
+ *                             {@literal @}Override
+ *                             public void onResponse(Call<ApiResponse> call,
+ *                                     Response<ApiResponse> response) {
+ *                                 // TODO insert new records into database
+ *                                 helperCallback.recordSuccess();
+ *                             }
+ *
+ *                             {@literal @}Override
+ *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
+ *                                 helperCallback.recordFailure(t);
+ *                             }
+ *                         }));
+ *     }
+ * }
+ * 
+ *

+ * The helper provides an API to observe combined request status, which can be reported back to the + * application based on your business rules. + *

+ * MutableLiveData<PagingRequestHelper.Status> combined = new MutableLiveData<>();
+ * helper.addListener(status -> {
+ *     // merge multiple states per request type into one, or dispatch separately depending on
+ *     // your application logic.
+ *     if (status.hasRunning()) {
+ *         combined.postValue(PagingRequestHelper.Status.RUNNING);
+ *     } else if (status.hasError()) {
+ *         // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
+ *         combined.postValue(PagingRequestHelper.Status.FAILED);
+ *     } else {
+ *         combined.postValue(PagingRequestHelper.Status.SUCCESS);
+ *     }
+ * });
+ * 
+ */ +// THIS class is likely to be moved into the library in a future release. Feel free to copy it +// from this sample. +public class PagingRequestHelper { + private final Object mLock = new Object(); + private final Executor mRetryService; + @GuardedBy("mLock") + private final RequestQueue[] mRequestQueues = new RequestQueue[] + {new RequestQueue(RequestType.INITIAL), + new RequestQueue(RequestType.BEFORE), + new RequestQueue(RequestType.AFTER)}; + @NonNull + final CopyOnWriteArrayList mListeners = new CopyOnWriteArrayList<>(); + /** + * Creates a new PagingRequestHelper with the given {@link Executor} which is used to run + * retry actions. + * + * @param retryService The {@link Executor} that can run the retry actions. + */ + public PagingRequestHelper(@NonNull Executor retryService) { + mRetryService = retryService; + } + /** + * Adds a new listener that will be notified when any request changes {@link Status state}. + * + * @param listener The listener that will be notified each time a request's status changes. + * @return True if it is added, false otherwise (e.g. it already exists in the list). + */ + @AnyThread + public boolean addListener(@NonNull Listener listener) { + return mListeners.add(listener); + } + /** + * Removes the given listener from the listeners list. + * + * @param listener The listener that will be removed. + * @return True if the listener is removed, false otherwise (e.g. it never existed) + */ + public boolean removeListener(@NonNull Listener listener) { + return mListeners.remove(listener); + } + /** + * Runs the given {@link Request} if no other requests in the given request type is already + * running. + *

+ * If run, the request will be run in the current thread. + * + * @param type The type of the request. + * @param request The request to run. + * @return True if the request is run, false otherwise. + */ + @SuppressWarnings("WeakerAccess") + @AnyThread + public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) { + boolean hasListeners = !mListeners.isEmpty(); + StatusReport report = null; + synchronized (mLock) { + RequestQueue queue = mRequestQueues[type.ordinal()]; + if (queue.mRunning != null) { + return false; + } + queue.mRunning = request; + queue.mStatus = Status.RUNNING; + queue.mFailed = null; + queue.mLastError = null; + if (hasListeners) { + report = prepareStatusReportLocked(); + } + } + if (report != null) { + dispatchReport(report); + } + final RequestWrapper wrapper = new RequestWrapper(request, this, type); + wrapper.run(); + return true; + } + @GuardedBy("mLock") + private StatusReport prepareStatusReportLocked() { + Throwable[] errors = new Throwable[]{ + mRequestQueues[0].mLastError, + mRequestQueues[1].mLastError, + mRequestQueues[2].mLastError + }; + return new StatusReport( + getStatusForLocked(RequestType.INITIAL), + getStatusForLocked(RequestType.BEFORE), + getStatusForLocked(RequestType.AFTER), + errors + ); + } + @GuardedBy("mLock") + private Status getStatusForLocked(RequestType type) { + return mRequestQueues[type.ordinal()].mStatus; + } + @AnyThread + @VisibleForTesting + void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) { + StatusReport report = null; + final boolean success = throwable == null; + boolean hasListeners = !mListeners.isEmpty(); + synchronized (mLock) { + RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; + queue.mRunning = null; + queue.mLastError = throwable; + if (success) { + queue.mFailed = null; + queue.mStatus = Status.SUCCESS; + } else { + queue.mFailed = wrapper; + queue.mStatus = Status.FAILED; + } + if (hasListeners) { + report = prepareStatusReportLocked(); + } + } + if (report != null) { + dispatchReport(report); + } + } + private void dispatchReport(StatusReport report) { + for (Listener listener : mListeners) { + listener.onStatusChange(report); + } + } + /** + * Retries all failed requests. + * + * @return True if any request is retried, false otherwise. + */ + public boolean retryAllFailed() { + final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; + boolean retried = false; + synchronized (mLock) { + for (int i = 0; i < RequestType.values().length; i++) { + toBeRetried[i] = mRequestQueues[i].mFailed; + mRequestQueues[i].mFailed = null; + } + } + for (RequestWrapper failed : toBeRetried) { + if (failed != null) { + failed.retry(mRetryService); + retried = true; + } + } + return retried; + } + static class RequestWrapper implements Runnable { + @NonNull + final Request mRequest; + @NonNull + final PagingRequestHelper mHelper; + @NonNull + final RequestType mType; + RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper, + @NonNull RequestType type) { + mRequest = request; + mHelper = helper; + mType = type; + } + @Override + public void run() { + mRequest.run(new Request.Callback(this, mHelper)); + } + void retry(Executor service) { + service.execute(new Runnable() { + @Override + public void run() { + mHelper.runIfNotRunning(mType, mRequest); + } + }); + } + } + /** + * Runner class that runs a request tracked by the {@link PagingRequestHelper}. + *

+ * When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)} + * or {@link Callback#recordSuccess()} once and only once. This call + * can be made any time. Until that method call is made, {@link PagingRequestHelper} will + * consider the request is running. + */ + @FunctionalInterface + public interface Request { + /** + * Should run the request and call the given {@link Callback} with the result of the + * request. + * + * @param callback The callback that should be invoked with the result. + */ + void run(Callback callback); + /** + * Callback class provided to the {@link #run(Callback)} method to report the result. + */ + class Callback { + private final AtomicBoolean mCalled = new AtomicBoolean(); + private final RequestWrapper mWrapper; + private final PagingRequestHelper mHelper; + Callback(RequestWrapper wrapper, PagingRequestHelper helper) { + mWrapper = wrapper; + mHelper = helper; + } + /** + * Call this method when the request succeeds and new data is fetched. + */ + @SuppressWarnings("unused") + public final void recordSuccess() { + if (mCalled.compareAndSet(false, true)) { + mHelper.recordResult(mWrapper, null); + } else { + throw new IllegalStateException( + "already called recordSuccess or recordFailure"); + } + } + /** + * Call this method with the failure message and the request can be retried via + * {@link #retryAllFailed()}. + * + * @param throwable The error that occured while carrying out the request. + */ + @SuppressWarnings("unused") + public final void recordFailure(@NonNull Throwable throwable) { + //noinspection ConstantConditions + if (throwable == null) { + throw new IllegalArgumentException("You must provide a throwable describing" + + " the error to record the failure"); + } + if (mCalled.compareAndSet(false, true)) { + mHelper.recordResult(mWrapper, throwable); + } else { + throw new IllegalStateException( + "already called recordSuccess or recordFailure"); + } + } + } + } + /** + * Data class that holds the information about the current status of the ongoing requests + * using this helper. + */ + public static final class StatusReport { + /** + * Status of the latest request that were submitted with {@link RequestType#INITIAL}. + */ + @NonNull + public final Status initial; + /** + * Status of the latest request that were submitted with {@link RequestType#BEFORE}. + */ + @NonNull + public final Status before; + /** + * Status of the latest request that were submitted with {@link RequestType#AFTER}. + */ + @NonNull + public final Status after; + @NonNull + private final Throwable[] mErrors; + StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after, + @NonNull Throwable[] errors) { + this.initial = initial; + this.before = before; + this.after = after; + this.mErrors = errors; + } + /** + * Convenience method to check if there are any running requests. + * + * @return True if there are any running requests, false otherwise. + */ + public boolean hasRunning() { + return initial == Status.RUNNING + || before == Status.RUNNING + || after == Status.RUNNING; + } + /** + * Convenience method to check if there are any requests that resulted in an error. + * + * @return True if there are any requests that finished with error, false otherwise. + */ + public boolean hasError() { + return initial == Status.FAILED + || before == Status.FAILED + || after == Status.FAILED; + } + /** + * Returns the error for the given request type. + * + * @param type The request type for which the error should be returned. + * @return The {@link Throwable} returned by the failing request with the given type or + * {@code null} if the request for the given type did not fail. + */ + @Nullable + public Throwable getErrorFor(@NonNull RequestType type) { + return mErrors[type.ordinal()]; + } + @Override + public String toString() { + return "StatusReport{" + + "initial=" + initial + + ", before=" + before + + ", after=" + after + + ", mErrors=" + Arrays.toString(mErrors) + + '}'; + } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StatusReport that = (StatusReport) o; + if (initial != that.initial) return false; + if (before != that.before) return false; + if (after != that.after) return false; + // Probably incorrect - comparing Object[] arrays with Arrays.equals + return Arrays.equals(mErrors, that.mErrors); + } + @Override + public int hashCode() { + int result = initial.hashCode(); + result = 31 * result + before.hashCode(); + result = 31 * result + after.hashCode(); + result = 31 * result + Arrays.hashCode(mErrors); + return result; + } + } + /** + * Listener interface to get notified by request status changes. + */ + public interface Listener { + /** + * Called when the status for any of the requests has changed. + * + * @param report The current status report that has all the information about the requests. + */ + void onStatusChange(@NonNull StatusReport report); + } + /** + * Represents the status of a Request for each {@link RequestType}. + */ + public enum Status { + /** + * There is current a running request. + */ + RUNNING, + /** + * The last request has succeeded or no such requests have ever been run. + */ + SUCCESS, + /** + * The last request has failed. + */ + FAILED + } + /** + * Available request types. + */ + public enum RequestType { + /** + * Corresponds to an initial request made to a {@link androidx.paging.DataSource} or the empty state for + * a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. + */ + INITIAL, + /** + * Corresponds to the {@code loadBefore} calls in {@link androidx.paging.DataSource} or + * {@code onItemAtFrontLoaded} in + * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. + */ + BEFORE, + /** + * Corresponds to the {@code loadAfter} calls in {@link androidx.paging.DataSource} or + * {@code onItemAtEndLoaded} in + * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. + */ + AFTER + } + class RequestQueue { + @NonNull + final RequestType mRequestType; + @Nullable + RequestWrapper mFailed; + @Nullable + Request mRunning; + @Nullable + Throwable mLastError; + @NonNull + Status mStatus = Status.SUCCESS; + RequestQueue(@NonNull RequestType requestType) { + mRequestType = requestType; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.java b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.java new file mode 100644 index 0000000..a0880a5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.java @@ -0,0 +1,94 @@ +package com.keylesspalace.tusky.util; + +import androidx.annotation.Nullable; +import androidx.arch.core.util.Function; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; + + +/** + * This list implementation can help to keep two lists in sync - like real models and view models. + * Every operation on the main list triggers update of the supplementary list (but not vice versa). + * This makes sure that the main list is always the source of truth. + * Main list is projected to the supplementary list by the passed mapper function. + * Paired list is newer actually exposed and clients are provided with {@code getPairedCopy()}, + * {@code getPairedItem()} and {@code setPairedItem()}. This prevents modifications of the + * supplementary list size so lists are always have the same length. + * This implementation will not try to recover from exceptional cases so lists may be out of sync + * after the exception. + * + * It is most useful with immutable data because we cannot track changes inside stored objects. + * @param type of elements in the main list + * @param type of elements in supplementary list + */ +public final class PairedList extends AbstractList { + private final List main = new ArrayList<>(); + private final List synced = new ArrayList<>(); + private final Function mapper; + + /** + * Construct new paired list. Main and supplementary lists will be empty. + * @param mapper Function, which will be used to translate items from the main list to the + * supplementary one. + */ + public PairedList(Function mapper) { + this.mapper = mapper; + } + + public List getPairedCopy() { + return new ArrayList<>(synced); + } + + public V getPairedItem(int index) { + return synced.get(index); + } + + @Nullable + public V getPairedItemOrNull(int index) { + if (index >= 0 && index < synced.size()) { + return synced.get(index); + } else { + return null; + } + } + + public void setPairedItem(int index, V element) { + synced.set(index, element); + } + + @Override + public T get(int index) { + return main.get(index); + } + + @Override + public T set(int index, T element) { + synced.set(index, mapper.apply(element)); + return main.set(index, element); + } + + @Override + public boolean add(T t) { + synced.add(mapper.apply(t)); + return main.add(t); + } + + @Override + public void add(int index, T element) { + synced.add(index, mapper.apply(element)); + main.add(index, element); + } + + @Override + public T remove(int index) { + synced.remove(index); + return main.remove(index); + } + + @Override + public int size() { + return main.size(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt new file mode 100644 index 0000000..1f9f35d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt @@ -0,0 +1,13 @@ +package com.keylesspalace.tusky.util + +sealed class Resource(open val data: T?) + +class Loading (override val data: T? = null) : Resource(data) + +class Success (override val data: T? = null) : Resource(data) + +class Error (override val data: T? = null, + val errorMessage: String? = null, + var consumed: Boolean = false, + val cause: Throwable? = null +): Resource(data) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt new file mode 100644 index 0000000..c78b0f7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt @@ -0,0 +1,18 @@ +package com.keylesspalace.tusky.util + +import androidx.annotation.CallSuper +import androidx.lifecycle.ViewModel +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable + +open class RxAwareViewModel : ViewModel() { + val disposables = CompositeDisposable() + + fun Disposable.autoDispose() = disposables.add(this) + + @CallSuper + override fun onCleared() { + super.onCleared() + disposables.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java new file mode 100644 index 0000000..7911a61 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java @@ -0,0 +1,57 @@ +package com.keylesspalace.tusky.util; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.keylesspalace.tusky.db.AppDatabase; +import com.keylesspalace.tusky.db.TootDao; +import com.keylesspalace.tusky.db.TootEntity; + +import java.util.ArrayList; + +import javax.inject.Inject; + +public final class SaveTootHelper { + + private static final String TAG = "SaveTootHelper"; + + private TootDao tootDao; + private Context context; + private Gson gson = new Gson(); + + @Inject + public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) { + this.tootDao = appDatabase.tootDao(); + this.context = context; + } + + public void deleteDraft(int tootId) { + TootEntity item = tootDao.find(tootId); + if (item != null) { + deleteDraft(item); + } + } + + public void deleteDraft(@NonNull TootEntity item) { + // Delete any media files associated with the status. + ArrayList uris = gson.fromJson(item.getUrls(), + new TypeToken>() { + }.getType()); + if (uris != null) { + for (String uriString : uris) { + Uri uri = Uri.parse(uriString); + if (context.getContentResolver().delete(uri, null, null) == 0) { + Log.e(TAG, String.format("Did not delete file %s.", uriString)); + } + } + } + // update DB + tootDao.delete(item.getUid()); + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt new file mode 100644 index 0000000..1acf226 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -0,0 +1,103 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +@file:JvmName("ShareShortcutHelper") + +package com.keylesspalace.tusky.util + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.text.TextUtils +import androidx.core.app.Person +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountEntity +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers + +fun updateShortcut(context: Context, account: AccountEntity) { + + Single.fromCallable { + + val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size) + val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size) + + val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) { + Glide.with(context) + .asBitmap() + .load(R.drawable.avatar_default) + .submit(innerSize, innerSize) + .get() + } else { + Glide.with(context) + .asBitmap() + .load(account.profilePictureUrl) + .error(R.drawable.avatar_default) + .submit(innerSize, innerSize) + .get() + } + + // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon + val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) + + val canvas = Canvas(outBmp) + canvas.drawBitmap(bmp, (outerSize - innerSize).toFloat() / 2f, (outerSize - innerSize).toFloat() / 2f, null) + + val icon = IconCompat.createWithAdaptiveBitmap(outBmp) + + val person = Person.Builder() + .setIcon(icon) + .setName(account.displayName) + .setKey(account.identifier) + .build() + + // This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(NotificationHelper.ACCOUNT_ID, account.id) + } + + val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString()) + .setIntent(intent) + .setCategories(setOf("com.keylesspalace.tusky.Share")) + .setShortLabel(account.displayName) + .setPerson(person) + .setLongLived(true) + .setIcon(icon) + .build() + + ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo)) + + } + .subscribeOn(Schedulers.io()) + .onErrorReturnItem(false) + .subscribe() + + +} + +fun removeShortcut(context: Context, account: AccountEntity) { + + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SharedPreferencesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/SharedPreferencesExtensions.kt new file mode 100644 index 0000000..a3835a8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SharedPreferencesExtensions.kt @@ -0,0 +1,7 @@ +package com.keylesspalace.tusky.util + +import android.content.SharedPreferences + +fun SharedPreferences.getNonNullString(key: String, defValue: String): String { + return this.getString(key, defValue) ?: defValue +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt new file mode 100644 index 0000000..ba9c420 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -0,0 +1,111 @@ +/* Copyright 2019 Tusky contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned + +/** + * Defines how many characters to extend beyond the limit to cut at the end of the word on the + * boundary of it rather than cutting at the word preceding that one. + */ +private const val RUNWAY = 10 + +/** + * Default for maximum status length on Mastodon and default collapsing length on Pleroma. + */ +private const val LENGTH_DEFAULT = 500 + +/** + * Calculates if it's worth trimming the message at a specific limit or if the content that will + * be hidden will not be enough to justify the operation. + * + * @param message The message to trim. + * @return Whether the message should be trimmed or not. + */ +fun shouldTrimStatus(message: Spanned): Boolean { + return message.isNotEmpty() && LENGTH_DEFAULT.toFloat() / message.length < 0.75 +} + +/** + * A customized version of {@link android.text.InputFilter.LengthFilter} which allows smarter + * constraints and adds better visuals such as: + *

    + *
  • Ellipsis at the end of the constrained text to show continuation.
  • + *
  • Trimming of invisible characters (new lines, spaces, etc.) from the constrained text.
  • + *
  • Constraints end at the end of the last "word", before a whitespace.
  • + *
  • Expansion of the limit by up to 10 characters to facilitate the previous constraint.
  • + *
  • Constraints are not applied if the percentage of hidden content is too small.
  • + *
+ */ +object SmartLengthInputFilter : InputFilter { + /** {@inheritDoc} */ + override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { + // Code originally imported from InputFilter.LengthFilter but heavily customized and converted to Kotlin. + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 + + val sourceLength = source.length + var keep = LENGTH_DEFAULT - (dest.length - (dend - dstart)) + if (keep <= 0) return "" + if (keep >= end - start) return null // Keep original + + keep += start + + // Skip trimming if the ratio doesn't warrant it + if (keep.toDouble() / sourceLength > 0.75) return null + + // Enable trimming at the end of the closest word if possible + if (source[keep].isLetterOrDigit()) { + var boundary: Int + + // Android N+ offer a clone of the ICU APIs in Java for better internationalization and + // unicode support. Using the ICU version of BreakIterator grants better support for + // those without having to add the ICU4J library at a minimum Api trade-off. + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + val iterator = android.icu.text.BreakIterator.getWordInstance() + iterator.setText(source.toString()) + boundary = iterator.following(keep) + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) + } else { + val iterator = java.text.BreakIterator.getWordInstance() + iterator.setText(source.toString()) + boundary = iterator.following(keep) + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) + } + + keep = boundary + } else { + + // If no runway is allowed simply remove whitespaces if present + while(source[keep - 1].isWhitespace()) { + --keep + if (keep == start) return "" + } + } + + if (source[keep - 1].isHighSurrogate()) { + --keep + if (keep == start) return "" + } + + return if (source is Spanned) { + SpannableStringBuilder(source, start, keep).append("…") + } else { + "${source.subSequence(start, keep)}…" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt new file mode 100644 index 0000000..ca68152 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -0,0 +1,161 @@ +package com.keylesspalace.tusky.util + +import android.text.Spannable +import android.text.Spanned +import android.text.style.CharacterStyle +import android.text.style.ForegroundColorSpan +import android.text.style.URLSpan +import java.util.regex.Pattern +import kotlin.math.max + +/** + * @see
+ * Tag#HASHTAG_RE. + */ +private const val TAG_REGEX = "(?:^|[^/)A-Za-z0-9_])#([\\w_]*[\\p{Alpha}_][\\w_]*)" + +/** + * @see + * Account#MENTION_RE + */ +private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z\\d_-]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)" + +private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)" +private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)" + +/** + * Dump of android.util.Patterns.WEB_URL + */ +private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?i:http|https|rtsp)://(?:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?(?:(([a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]](?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]_\\-]{0,61}[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]]){0,1}\\.)+(xn\\-\\-[\\w\\-]{0,58}\\w|[a-zA-Z[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]]{2,63})|((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[0-9]))))(?:\\:\\d{1,5})?)([/\\?](?:(?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]];/\\?:@&=#~\\-\\.\\+!\\*'\\(\\),_\\\$])|(?:%[a-fA-F0-9]{2}))*)?(?:\\b|\$|^))") + +private val spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java) +private val finders = mapOf( + FoundMatchType.HTTP_URL to PatternFinder(':', HTTP_URL_REGEX, 5, Character::isWhitespace), + FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6, Character::isWhitespace), + FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1, ::isValidForTagPrefix), + FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1, Character::isWhitespace) // TODO: We also need a proper validator for mentions +) + +private enum class FoundMatchType { + HTTP_URL, + HTTPS_URL, + TAG, + MENTION, +} + +private class FindCharsResult { + lateinit var matchType: FoundMatchType + var start: Int = -1 + var end: Int = -1 +} + +private class PatternFinder(val searchCharacter: Char, regex: String, val searchPrefixWidth: Int, + val prefixValidator: (Int) -> Boolean) { + val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) +} + +private fun clearSpans(text: Spannable, spanClass: Class) { + for(span in text.getSpans(0, text.length, spanClass)) { + text.removeSpan(span) + } +} + +private fun findPattern(string: String, fromIndex: Int): FindCharsResult { + val result = FindCharsResult() + for (i in fromIndex..string.lastIndex) { + val c = string[i] + for (matchType in FoundMatchType.values()) { + val finder = finders[matchType] + if (finder!!.searchCharacter == c + && ((i - fromIndex) < finder.searchPrefixWidth || + finder.prefixValidator(string.codePointAt(i - finder.searchPrefixWidth)))) { + result.matchType = matchType + result.start = max(0, i - finder.searchPrefixWidth) + findEndOfPattern(string, result, finder.pattern) + if (result.start + finder.searchPrefixWidth <= i + 1 && // The found result is actually triggered by the correct search character + result.end >= result.start) { // ...and we actually found a valid result + return result + } + } + } + } + return result +} + +private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) { + val matcher = pattern.matcher(string) + if (matcher.find(result.start)) { + // Once we have API level 26+, we can use named captures... + val end = matcher.end() + result.start = matcher.start() + when (result.matchType) { + FoundMatchType.TAG -> { + if (isValidForTagPrefix(string.codePointAt(result.start))) { + if (string[result.start] != '#' || + (string[result.start] == '#' && string[result.start + 1] == '#')) { + ++result.start + } + } + } + else -> { + if (Character.isWhitespace(string.codePointAt(result.start))) { + ++result.start + } + } + } + when (result.matchType) { + FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> { + // Preliminary url patterns are fast/permissive, now we'll do full validation + if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) { + result.end = end + } + } + else -> result.end = end + } + } +} + +private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle { + return when(matchType) { + FoundMatchType.HTTP_URL -> CustomURLSpan(string.substring(start, end)) + FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end)) + else -> ForegroundColorSpan(colour) + } +} + +/** Takes text containing mentions and hashtags and urls and makes them the given colour. */ +fun highlightSpans(text: Spannable, colour: Int) { + // Strip all existing colour spans. + for (spanClass in spanClasses) { + clearSpans(text, spanClass) + } + + // Colour the mentions and hashtags. + val string = text.toString() + val length = text.length + var start = 0 + var end = 0 + while (end >= 0 && end < length && start >= 0) { + // Search for url first because it can contain the other characters + val found = findPattern(string, end) + start = found.start + end = found.end + if (start >= 0 && end > start) { + text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + start += finders[found.matchType]!!.searchPrefixWidth + } + } +} + +private fun isWordCharacters(codePoint: Int): Boolean { + return (codePoint in 0x30..0x39) || // [0-9] + (codePoint in 0x41..0x5a) || // [A-Z] + (codePoint == 0x5f) || // _ + (codePoint in 0x61..0x7a) // [a-z] +} + +private fun isValidForTagPrefix(codePoint: Int): Boolean { + return !(isWordCharacters(codePoint) || // \w + (codePoint == 0x2f) || // / + (codePoint == 0x29)) // ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt new file mode 100644 index 0000000..7321605 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -0,0 +1,22 @@ +package com.keylesspalace.tusky.util + +data class StatusDisplayOptions( + @get:JvmName("animateAvatars") + val animateAvatars: Boolean, + @get:JvmName("mediaPreviewEnabled") + val mediaPreviewEnabled: Boolean, + @get:JvmName("useAbsoluteTime") + val useAbsoluteTime: Boolean, + @get:JvmName("showBotOverlay") + val showBotOverlay: Boolean, + @get:JvmName("useBlurhash") + val useBlurhash: Boolean, + @get:JvmName("cardViewMode") + val cardViewMode: CardViewMode, + @get:JvmName("confirmReblogs") + val confirmReblogs: Boolean, + @get:JvmName("renderStatusAsMention") + val renderStatusAsMention: Boolean, + @get:JvmName("hideStats") + val hideStats: Boolean +) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt new file mode 100644 index 0000000..7b0cc34 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -0,0 +1,331 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.text.InputFilter +import android.text.TextUtils +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.view.MediaPreviewImageView +import com.keylesspalace.tusky.viewdata.PollViewData +import com.keylesspalace.tusky.viewdata.buildDescription +import com.keylesspalace.tusky.viewdata.calculatePercent +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.min + +class StatusViewHelper(private val itemView: View) { + interface MediaPreviewListener { + fun onViewMedia(v: View?, idx: Int) + fun onContentHiddenChange(isShowing: Boolean) + } + + private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) + + fun setMediasPreview( + statusDisplayOptions: StatusDisplayOptions, + attachments: List, + sensitive: Boolean, + previewListener: MediaPreviewListener, + showingContent: Boolean, + mediaPreviewHeight: Int) { + + val context = itemView.context + val mediaPreviews = arrayOf( + itemView.findViewById(R.id.status_media_preview_0), + itemView.findViewById(R.id.status_media_preview_1), + itemView.findViewById(R.id.status_media_preview_2), + itemView.findViewById(R.id.status_media_preview_3)) + + val mediaOverlays = arrayOf( + itemView.findViewById(R.id.status_media_overlay_0), + itemView.findViewById(R.id.status_media_overlay_1), + itemView.findViewById(R.id.status_media_overlay_2), + itemView.findViewById(R.id.status_media_overlay_3)) + + val sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning) + val sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button) + val mediaLabel = itemView.findViewById(R.id.status_media_label) + if (statusDisplayOptions.mediaPreviewEnabled) { + // Hide the unused label. + mediaLabel.visibility = View.GONE + } else { + setMediaLabel(mediaLabel, attachments, sensitive, previewListener) + // Hide all unused views. + mediaPreviews[0].visibility = View.GONE + mediaPreviews[1].visibility = View.GONE + mediaPreviews[2].visibility = View.GONE + mediaPreviews[3].visibility = View.GONE + sensitiveMediaWarning.visibility = View.GONE + sensitiveMediaShow.visibility = View.GONE + return + } + + + val mediaPreviewUnloaded = ColorDrawable(ThemeUtils.getColor(context, R.attr.colorBackgroundAccent)) + + val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS) + + for (i in 0 until n) { + val attachment = attachments[i] + val previewUrl = attachment.previewUrl + val description = attachment.description + + if (TextUtils.isEmpty(description)) { + mediaPreviews[i].contentDescription = context.getString(R.string.action_view_media) + } else { + mediaPreviews[i].contentDescription = description + } + + mediaPreviews[i].visibility = View.VISIBLE + + if (TextUtils.isEmpty(previewUrl)) { + Glide.with(mediaPreviews[i]) + .load(mediaPreviewUnloaded) + .centerInside() + .into(mediaPreviews[i]) + } else { + val placeholder = if (attachment.blurhash != null) + decodeBlurHash(context, attachment.blurhash) + else mediaPreviewUnloaded + val meta = attachment.meta + val focus = meta?.focus + if (showingContent) { + if (focus != null) { // If there is a focal point for this attachment: + mediaPreviews[i].setFocalPoint(focus) + + Glide.with(mediaPreviews[i]) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(mediaPreviews[i]) + .into(mediaPreviews[i]) + } else { + mediaPreviews[i].removeFocalPoint() + + Glide.with(mediaPreviews[i]) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(mediaPreviews[i]) + } + } else { + mediaPreviews[i].removeFocalPoint() + if (statusDisplayOptions.useBlurhash && attachment.blurhash != null) { + val blurhashBitmap = decodeBlurHash(context, attachment.blurhash) + mediaPreviews[i].setImageDrawable(blurhashBitmap) + } else { + mediaPreviews[i].setImageDrawable(mediaPreviewUnloaded) + } + } + } + + val type = attachment.type + if (showingContent + && (type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) { + mediaOverlays[i].visibility = View.VISIBLE + } else { + mediaOverlays[i].visibility = View.GONE + } + + mediaPreviews[i].setOnClickListener { v -> + previewListener.onViewMedia(v, i) + } + + if (n <= 2) { + mediaPreviews[0].layoutParams.height = mediaPreviewHeight * 2 + mediaPreviews[1].layoutParams.height = mediaPreviewHeight * 2 + } else { + mediaPreviews[0].layoutParams.height = mediaPreviewHeight + mediaPreviews[1].layoutParams.height = mediaPreviewHeight + mediaPreviews[2].layoutParams.height = mediaPreviewHeight + mediaPreviews[3].layoutParams.height = mediaPreviewHeight + } + } + if (attachments.isNullOrEmpty()) { + sensitiveMediaWarning.visibility = View.GONE + sensitiveMediaShow.visibility = View.GONE + } else { + sensitiveMediaWarning.text = if (sensitive) { + context.getString(R.string.status_sensitive_media_title) + } else { + context.getString(R.string.status_media_hidden_title) + } + + sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE + sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE + sensitiveMediaShow.setOnClickListener { v -> + previewListener.onContentHiddenChange(false) + v.visibility = View.GONE + sensitiveMediaWarning.visibility = View.VISIBLE + setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener, + false, mediaPreviewHeight) + } + sensitiveMediaWarning.setOnClickListener { v -> + previewListener.onContentHiddenChange(true) + v.visibility = View.GONE + sensitiveMediaShow.visibility = View.VISIBLE + setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener, + true, mediaPreviewHeight) + } + } + + // Hide any of the placeholder previews beyond the ones set. + for (i in n until Status.MAX_MEDIA_ATTACHMENTS) { + mediaPreviews[i].visibility = View.GONE + } + } + + private fun setMediaLabel(mediaLabel: TextView, attachments: List, sensitive: Boolean, + listener: MediaPreviewListener) { + if (attachments.isEmpty()) { + mediaLabel.visibility = View.GONE + return + } + mediaLabel.visibility = View.VISIBLE + + // Set the label's text. + val context = mediaLabel.context + var labelText = getLabelTypeText(context, attachments[0].type) + if (sensitive) { + val sensitiveText = context.getString(R.string.status_sensitive_media_title) + labelText += String.format(" (%s)", sensitiveText) + } + mediaLabel.text = labelText + + // Set the icon next to the label. + val drawableId = getLabelIcon(attachments[0].type) + mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0) + + mediaLabel.setOnClickListener { listener.onViewMedia(null, 0) } + } + + private fun getLabelTypeText(context: Context, type: Attachment.Type): String { + return when (type) { + Attachment.Type.IMAGE -> context.getString(R.string.status_media_images) + Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.status_media_video) + Attachment.Type.AUDIO -> context.getString(R.string.status_media_audio) + else -> context.getString(R.string.status_media_attachments) + } + } + + @DrawableRes + private fun getLabelIcon(type: Attachment.Type): Int { + return when (type) { + Attachment.Type.IMAGE -> R.drawable.ic_photo_24dp + Attachment.Type.GIFV, Attachment.Type.VIDEO -> R.drawable.ic_videocam_24dp + Attachment.Type.AUDIO -> R.drawable.ic_music_box_24dp + else -> R.drawable.ic_attach_file_24dp + } + } + + fun setupPollReadonly(poll: PollViewData?, emojis: List, useAbsoluteTime: Boolean) { + val pollResults = listOf( + itemView.findViewById(R.id.status_poll_option_result_0), + itemView.findViewById(R.id.status_poll_option_result_1), + itemView.findViewById(R.id.status_poll_option_result_2), + itemView.findViewById(R.id.status_poll_option_result_3)) + + val pollDescription = itemView.findViewById(R.id.status_poll_description) + + if (poll == null) { + for (pollResult in pollResults) { + pollResult.visibility = View.GONE + } + pollDescription.visibility = View.GONE + } else { + val timestamp = System.currentTimeMillis() + + + setupPollResult(poll, emojis, pollResults) + + pollDescription.visibility = View.VISIBLE + pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, useAbsoluteTime) + } + } + + private fun getPollInfoText(timestamp: Long, poll: PollViewData, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence { + val context = pollDescription.context + val votesText = if(poll.votersCount == null) { + val votes = NumberFormat.getNumberInstance().format(poll.votesCount.toLong()) + context.resources.getQuantityString(R.plurals.poll_info_votes, poll.votesCount, votes) + } else { + val votes = NumberFormat.getNumberInstance().format(poll.votersCount.toLong()) + context.resources.getQuantityString(R.plurals.poll_info_people, poll.votersCount, votes) + } + val pollDurationInfo = if (poll.expired) { + context.getString(R.string.poll_info_closed) + } else { + if (useAbsoluteTime) { + context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt)) + } else { + TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp) + } + } + + return context.getString(R.string.poll_info_format, votesText, pollDurationInfo) + } + + + private fun setupPollResult(poll: PollViewData, emojis: List, pollResults: List) { + val options = poll.options + + for (i in 0 until Status.MAX_POLL_OPTIONS) { + if (i < options.size) { + val percent = calculatePercent(options[i].votesCount, poll.votersCount, poll.votesCount) + + val pollOptionText = buildDescription(options[i].title, percent, pollResults[i].context) + pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i]) + pollResults[i].visibility = View.VISIBLE + + val level = percent * 100 + + pollResults[i].background.level = level + + } else { + pollResults[i].visibility = View.GONE + } + } + } + + fun getAbsoluteTime(time: Date?): String { + return if (time != null) { + if (android.text.format.DateUtils.isToday(time.time)) { + shortSdf.format(time) + } else { + longSdf.format(time) + } + } else { + "??:??:??" + } + } + + companion object { + val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt new file mode 100644 index 0000000..83eaeaf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -0,0 +1,91 @@ +@file:JvmName("StringUtils") + +package com.keylesspalace.tusky.util + +import android.text.Spanned +import java.util.* + + +private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +fun randomAlphanumericString(count: Int): String { + val chars = CharArray(count) + val random = Random() + for (i in 0 until count) { + chars[i] = POSSIBLE_CHARS[random.nextInt(POSSIBLE_CHARS.length)] + } + return String(chars) +} + +// We sort statuses by ID. Something we need to invent some ID for placeholder. +// Not sure if inc()/dec() should be made `operator` or not + +/** + * "Increment" string so that during sorting it's bigger than [this]. + */ +fun String.inc(): String { + // We assume that we will stay in the safe range for now + val builder = this.toCharArray() + builder[lastIndex] = builder[lastIndex].inc() + return String(builder) +} + + +/** + * "Decrement" string so that during sorting it's smaller than [this]. + */ +fun String.dec(): String { + if (this.isEmpty()) return this + + val builder = this.toCharArray() + var i = builder.lastIndex + while (i > 0) { + if (builder[i] > '0') { + builder[i] = builder[i].dec() + return String(builder) + } else { + builder[i] = 'z' + } + i-- + } + return if (builder[0] > '1') { + builder[0] = builder[0].dec() + String(builder) + } else { + String(builder.copyOfRange(1, builder.size)) + } +} + +/** + * A < B (strictly) by length and then by content. + * Examples: + * "abc" < "bcd" + * "ab" < "abc" + * "cb" < "abc" + * not: "ab" < "ab" + * not: "abc" > "cb" + */ +fun String.isLessThan(other: String): Boolean { + return when { + this.length < other.length -> true + this.length > other.length -> false + else -> this < other + } +} + +fun Spanned.trimTrailingWhitespace(): Spanned { + var i = length + do { + i-- + } while (i >= 0 && get(i).isWhitespace()) + return subSequence(0, i + 1) as Spanned +} + +/** + * BidiFormatter.unicodeWrap is insufficient in some cases (see #1921) + * So we force isolation manually + * https://unicode.org/reports/tr9/#Explicit_Directional_Isolates + */ +fun CharSequence.unicodeWrap(): String { + return "\u2068${this}\u2069" +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java new file mode 100644 index 0000000..8c04a7d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java @@ -0,0 +1,83 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util; + +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.TypedValue; + +import androidx.annotation.AttrRes; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatDelegate; + +/** + * Provides runtime compatibility to obtain theme information and re-theme views, especially where + * the ability to do so is not supported in resource files. + */ +public class ThemeUtils { + + public static final String APP_THEME_DEFAULT = ThemeUtils.THEME_NIGHT; + + private static final String THEME_NIGHT = "night"; + private static final String THEME_DAY = "day"; + private static final String THEME_BLACK = "black"; + private static final String THEME_AUTO = "auto"; + private static final String THEME_SYSTEM = "auto_system"; + + @ColorInt + public static int getColor(@NonNull Context context, @AttrRes int attribute) { + TypedValue value = new TypedValue(); + if (context.getTheme().resolveAttribute(attribute, value, true)) { + return value.data; + } else { + return Color.BLACK; + } + } + + public static int getDimension(@NonNull Context context, @AttrRes int attribute) { + TypedArray array = context.obtainStyledAttributes(new int[] { attribute }); + int dimen = array.getDimensionPixelSize(0, -1); + array.recycle(); + return dimen; + } + + public static void setDrawableTint(Context context, Drawable drawable, @AttrRes int attribute) { + drawable.setColorFilter(getColor(context, attribute), PorterDuff.Mode.SRC_IN); + } + + public static void setAppNightMode(String flavor) { + switch (flavor) { + default: + case THEME_NIGHT: + case THEME_BLACK: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case THEME_DAY: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case THEME_AUTO: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_TIME); + break; + case THEME_SYSTEM: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + break; + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java new file mode 100644 index 0000000..c94b422 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java @@ -0,0 +1,105 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util; + +import android.content.Context; + +import com.keylesspalace.tusky.R; + +public class TimestampUtils { + + private static final long SECOND_IN_MILLIS = 1000; + private static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60; + private static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60; + private static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24; + private static final long YEAR_IN_MILLIS = DAY_IN_MILLIS * 365; + + /** + * This is a rough duplicate of {@link android.text.format.DateUtils#getRelativeTimeSpanString}, + * but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough. + */ + public static String getRelativeTimeSpanString(Context context, long then, long now) { + long span = now - then; + boolean future = false; + if (span < 0) { + future = true; + span = -span; + } + int format; + if (span < MINUTE_IN_MILLIS) { + span /= SECOND_IN_MILLIS; + if (future) { + format = R.string.abbreviated_in_seconds; + } else { + format = R.string.abbreviated_seconds_ago; + } + } else if (span < HOUR_IN_MILLIS) { + span /= MINUTE_IN_MILLIS; + if (future) { + format = R.string.abbreviated_in_minutes; + } else { + format = R.string.abbreviated_minutes_ago; + } + } else if (span < DAY_IN_MILLIS) { + span /= HOUR_IN_MILLIS; + if (future) { + format = R.string.abbreviated_in_hours; + } else { + format = R.string.abbreviated_hours_ago; + } + } else if (span < YEAR_IN_MILLIS) { + span /= DAY_IN_MILLIS; + if (future) { + format = R.string.abbreviated_in_days; + } else { + format = R.string.abbreviated_days_ago; + } + } else { + span /= YEAR_IN_MILLIS; + if (future) { + format = R.string.abbreviated_in_years; + } else { + format = R.string.abbreviated_years_ago; + } + } + return context.getString(format, span); + } + + public static String formatPollDuration(Context context, long then, long now) { + long span = then - now; + if (span < 0) { + span = 0; + } + int format; + if (span < MINUTE_IN_MILLIS) { + span /= SECOND_IN_MILLIS; + format = R.plurals.poll_timespan_seconds; + } else if (span < HOUR_IN_MILLIS) { + span /= MINUTE_IN_MILLIS; + format = R.plurals.poll_timespan_minutes; + + } else if (span < DAY_IN_MILLIS) { + span /= HOUR_IN_MILLIS; + format = R.plurals.poll_timespan_hours; + + } else { + span /= DAY_IN_MILLIS; + format = R.plurals.poll_timespan_days; + } + return context.getResources().getQuantityString(format, (int) span, (int) span); + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java new file mode 100644 index 0000000..ef4801a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java @@ -0,0 +1,49 @@ +/* Copyright 2019 kyori19 + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util; + +import androidx.annotation.NonNull; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class VersionUtils { + + private int major; + private int minor; + private int patch; + private String versionString; + + public VersionUtils(@NonNull String versionString) { + this.versionString = versionString; + String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(versionString); + if (matcher.find()) { + major = Integer.parseInt(matcher.group(1)); + minor = Integer.parseInt(matcher.group(2)); + patch = Integer.parseInt(matcher.group(3)); + } + } + + public boolean supportsScheduledToots() { + return (major == 2) ? ( (minor == 7) ? (patch >= 0) : (minor > 7) ) : (major > 2); + } + + public boolean isPleroma() { + return versionString.contains(" (compatible; Pleroma "); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java new file mode 100644 index 0000000..abcd8d8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -0,0 +1,128 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.Chat; +import com.keylesspalace.tusky.entity.ChatMessage; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; +import com.keylesspalace.tusky.viewdata.ChatViewData; +import com.keylesspalace.tusky.viewdata.ChatMessageViewData; + +/** + * Created by charlag on 12/07/2017. + */ + +public final class ViewDataUtils { + @Nullable + public static StatusViewData.Concrete statusToViewData(@Nullable Status status, + boolean alwaysShowSensitiveMedia, + boolean alwaysOpenSpoiler) { + if (status == null) return null; + Status visibleStatus = status.getReblog() == null ? status : status.getReblog(); + return new StatusViewData.Builder().setId(status.getId()) + .setAttachments(visibleStatus.getAttachments()) + .setAvatar(visibleStatus.getAccount().getAvatar()) + .setContent(visibleStatus.getContent()) + .setCreatedAt(visibleStatus.getCreatedAt()) + .setReblogsCount(visibleStatus.getReblogsCount()) + .setFavouritesCount(visibleStatus.getFavouritesCount()) + .setInReplyToId(visibleStatus.getInReplyToId()) + .setInReplyToAccountAcct(visibleStatus.getInReplyToAccountAcct()) + .setFavourited(visibleStatus.getFavourited()) + .setBookmarked(visibleStatus.getBookmarked()) + .setReblogged(visibleStatus.getReblogged()) + .setIsExpanded(alwaysOpenSpoiler) + .setIsShowingSensitiveContent(false) + .setMentions(visibleStatus.getMentions()) + .setNickname(visibleStatus.getAccount().getUsername()) + .setRebloggedAvatar(status.getReblog() == null ? null : status.getAccount().getAvatar()) + .setSensitive(visibleStatus.getSensitive()) + .setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive()) + .setSpoilerText(visibleStatus.getSpoilerText()) + .setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getDisplayName()) + .setUserFullName(visibleStatus.getAccount().getName()) + .setVisibility(visibleStatus.getVisibility()) + .setSenderId(visibleStatus.getAccount().getId()) + .setRebloggingEnabled(visibleStatus.rebloggingAllowed()) + .setApplication(visibleStatus.getApplication()) + .setStatusEmojis(visibleStatus.getEmojis()) + .setAccountEmojis(visibleStatus.getAccount().getEmojis()) + .setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis()) + .setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent())) + .setCollapsed(true) + .setPoll(visibleStatus.getPoll()) + .setCard(visibleStatus.getCard()) + .setIsBot(visibleStatus.getAccount().getBot()) + .setMuted(visibleStatus.isMuted()) + .setUserMuted(visibleStatus.isUserMuted()) + .setThreadMuted(visibleStatus.isThreadMuted()) + .setConversationId(visibleStatus.getConversationId()) + .setEmojiReactions(visibleStatus.getEmojiReactions()) + .setParentVisible(visibleStatus.getParentVisible()) + .createStatusViewData(); + } + + public static NotificationViewData.Concrete notificationToViewData(Notification notification, + boolean alwaysShowSensitiveData, + boolean alwaysOpenSpoiler) { + return new NotificationViewData.Concrete( + notification.getType(), + notification.getId(), + notification.getAccount(), + statusToViewData( + notification.getStatus(), + alwaysShowSensitiveData, + alwaysOpenSpoiler + ), + notification.getEmoji(), + notification.getTarget() + ); + } + + public static ChatMessageViewData.Concrete chatMessageToViewData(@Nullable ChatMessage msg) { + if(msg == null) return null; + + return new ChatMessageViewData.Concrete( + msg.getId(), + msg.getContent(), + msg.getChatId(), + msg.getAccountId(), + msg.getCreatedAt(), + msg.getAttachment(), + msg.getEmojis(), + msg.getCard() + ); + } + + @NonNull + public static ChatViewData.Concrete chatToViewData(Chat chat) { + return new ChatViewData.Concrete( + chat.getAccount(), + chat.getId(), + chat.getUnread(), + chatMessageToViewData( + chat.getLastMessage() + ), + chat.getUpdatedAt() + ); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt new file mode 100644 index 0000000..ae9a885 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -0,0 +1,115 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.util + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.EditText +import android.util.TypedValue +import android.content.res.Resources +import android.view.TouchDelegate +import android.view.MotionEvent +import android.graphics.Rect +/*import java.util.List +import java.util.ArrayList*/ + +fun View.show() { + this.visibility = View.VISIBLE +} + +fun View.hide() { + this.visibility = View.GONE +} + +fun View.visible(visible: Boolean, or: Int = View.GONE) { + this.visibility = if (visible) View.VISIBLE else or +} + +class MultipleTouchDelegate : TouchDelegate { + + var delegates = mutableListOf() + + constructor(v: View) : super(Rect(), v) + + public fun addDelegate(delegate: TouchDelegate) { + delegates.add(delegate) + } + + override fun onTouchEvent(event: MotionEvent) : Boolean { + var ret = false + val x = event.x + val y = event.y + + for(delegate in delegates) { + event.setLocation(x, y) + ret = delegate.onTouchEvent(event) || ret + } + + return ret + } +} + +fun View.increaseHitArea(vdp: Float, hdp: Float) { + val parent = this.parent as View + val vpixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, vdp, Resources.getSystem().displayMetrics).toInt() + val hpixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, hdp, Resources.getSystem().displayMetrics).toInt() + parent.post { + val rect = Rect() + this.getHitRect(rect) + rect.top -= vpixels + rect.left -= hpixels + rect.bottom += vpixels + rect.right += hpixels + if(parent.touchDelegate != null && parent.touchDelegate is MultipleTouchDelegate) { + (parent.touchDelegate as MultipleTouchDelegate).addDelegate(TouchDelegate(rect, this)) + } else { + val mtd = MultipleTouchDelegate(this) + mtd.addDelegate(TouchDelegate(rect, this)) + parent.touchDelegate = mtd + } + } +} + +open class DefaultTextWatcher : TextWatcher { + override fun afterTextChanged(s: Editable) { + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + } +} + +inline fun EditText.onTextChanged( + crossinline callback: (s: CharSequence, start: Int, before: Int, count: Int) -> Unit) { + addTextChangedListener(object : DefaultTextWatcher() { + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + callback(s, start, before, count) + } + }) +} + +inline fun EditText.afterTextChanged( + crossinline callback: (s: Editable) -> Unit) { + addTextChangedListener(object : DefaultTextWatcher() { + override fun afterTextChanged(s: Editable) { + callback(s) + } + }) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewPager2Fix.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewPager2Fix.java new file mode 100644 index 0000000..4698f44 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewPager2Fix.java @@ -0,0 +1,40 @@ +package com.keylesspalace.tusky.util; + +import androidx.viewpager2.widget.ViewPager2; +import androidx.recyclerview.widget.RecyclerView; +import java.lang.reflect.*; +import java.lang.*; + +/** + * ViewPager2 written by monkeys! + */ +public class ViewPager2Fix { + /** + * Thanks to @al.e.shevelev@medium.com for solution + */ + public static Field getViewPagerRecyclerViewField() throws NoSuchFieldException { + Field f = ViewPager2.class.getDeclaredField("mRecyclerView"); + f.setAccessible(true); + return f; + } + + public static Field getRecyclerViewTouchSlopField() throws NoSuchFieldException { + Field f = RecyclerView.class.getDeclaredField("mTouchSlop"); + f.setAccessible(true); + return f; + } + + public static void reduceVelocity(ViewPager2 pager, float val) { + try { + Field recyclerViewField = getViewPagerRecyclerViewField(); + Field touchSlopField = getRecyclerViewTouchSlopField(); + + RecyclerView recyclerView = (RecyclerView)recyclerViewField.get(pager); + int touchSlop = (int)touchSlopField.get(recyclerView); + touchSlopField.setInt(recyclerView, (int)(touchSlop*val)); + } catch(Exception e) { + // all possible exceptions must be caught during tests + ; + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt b/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt new file mode 100644 index 0000000..b003cb2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt @@ -0,0 +1,23 @@ +package com.keylesspalace.tusky.util + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String { + return PagingRequestHelper.RequestType.values().mapNotNull { + report.getErrorFor(it)?.message + }.first() +} + +fun PagingRequestHelper.createStatusLiveData(): LiveData { + val liveData = MutableLiveData() + addListener { report -> + when { + report.hasRunning() -> liveData.postValue(NetworkState.LOADING) + report.hasError() -> liveData.postValue( + NetworkState.error(getErrorMessage(report))) + else -> liveData.postValue(NetworkState.LOADED) + } + } + return liveData +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt new file mode 100644 index 0000000..4789ac3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -0,0 +1,46 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.visible +import kotlinx.android.synthetic.main.view_background_message.view.* + + +/** + * This view is used for screens with downloadable content which may fail. + * Can show an image, text and button below them. + */ +class BackgroundMessageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + init { + View.inflate(context, R.layout.view_background_message, this) + gravity = Gravity.CENTER_HORIZONTAL + orientation = VERTICAL + + if (isInEditMode) { + setup(R.drawable.elephant_offline, R.string.error_network) {} + } + } + + /** + * Setup image, message and button. + * If [clickListener] is `null` then the button will be hidden. + */ + fun setup(@DrawableRes imageRes: Int, @StringRes messageRes: Int, + clickListener: ((v: View) -> Unit)? = null) { + messageTextView.setText(messageRes) + imageView.setImageResource(imageRes) + button.setOnClickListener(clickListener) + button.visible(clickListener != null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BezelImageView.java b/app/src/main/java/com/keylesspalace/tusky/view/BezelImageView.java new file mode 100644 index 0000000..c31b37e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/BezelImageView.java @@ -0,0 +1,61 @@ +/* Copyright 2019 Tusky contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view; + +import android.content.Context; +import android.graphics.Outline; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewOutlineProvider; + +/** + * override BezelImageView from MaterialDrawer library to provide custom outline + */ + +public class BezelImageView extends com.mikepenz.materialdrawer.view.BezelImageView { + public BezelImageView(Context context) { + this(context, null); + } + + public BezelImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public BezelImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onSizeChanged(int w, int h, int old_w, int old_h) { + setOutlineProvider(new CustomOutline(w, h)); + } + + private static class CustomOutline extends ViewOutlineProvider { + + int width; + int height; + + CustomOutline(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, width, height, width < height ? width / 8f : height / 8f); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt new file mode 100644 index 0000000..4011d69 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt @@ -0,0 +1,73 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import androidx.recyclerview.widget.RecyclerView +import android.view.View +import androidx.core.content.ContextCompat + +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.ThreadAdapter + +class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() { + + private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!! + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) + val dividerEnd = dividerStart + divider.intrinsicWidth + + val childCount = parent.childCount + val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) + + for (i in 0 until childCount) { + val child = parent.getChildAt(i) + + val position = parent.getChildAdapterPosition(child) + val adapter = parent.adapter as ThreadAdapter + + val current = adapter.getItem(position) + val dividerTop: Int + val dividerBottom: Int + if (current != null) { + val above = adapter.getItem(position - 1) + dividerTop = if (above != null && above.id == current.inReplyToId) { + child.top + } else { + child.top + avatarMargin + } + val below = adapter.getItem(position + 1) + dividerBottom = if (below != null && current.id == below.inReplyToId && + adapter.detailedStatusPosition != position) { + child.bottom + } else { + child.top + avatarMargin + } + + if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + divider.setBounds(dividerStart, dividerTop, dividerEnd, dividerBottom) + } else { + divider.setBounds(canvas.width - dividerEnd, dividerTop, canvas.width - dividerStart, dividerBottom) + } + divider.draw(canvas) + + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/CustomEmojiTextView.kt b/app/src/main/java/com/keylesspalace/tusky/view/CustomEmojiTextView.kt new file mode 100644 index 0000000..d3a8515 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/CustomEmojiTextView.kt @@ -0,0 +1,60 @@ +package com.keylesspalace.tusky.view + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.text.Layout +import android.text.Spannable +import android.util.AttributeSet +import android.util.Log +import androidx.emoji.widget.EmojiAppCompatTextView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.util.EmojiSpan +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.emojify + +/* + * This is a TextView that changes break strategy to simple + * if there is too much custom emojis + * + * Fixes Android performance bug + */ + +class CustomEmojiTextView +@JvmOverloads constructor(context:Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +): EmojiAppCompatTextView(context, attrs, defStyleAttr) { + private var oldBreakStrategy = 1 // Layout.BREAK_STRATEGY_HIGH_QUALITY + + @SuppressLint("WrongConstant") + override fun setText(text: CharSequence?, type: BufferType?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + var overridden = false + + // don't change if break strategy already simple + if(text is Spannable && breakStrategy != Layout.BREAK_STRATEGY_SIMPLE) { + val spans = text.getSpans(0, text.length, EmojiSpan::class.java) + + if (spans.size >= SPAN_LIMIT) { + oldBreakStrategy = breakStrategy + breakStrategy = Layout.BREAK_STRATEGY_SIMPLE + overridden = true + + Log.d("CustomEmojiTextView", "break strategy overriden!"); + } + } + + if(!overridden) + breakStrategy = oldBreakStrategy + } + + super.setText(text, type) + } + + companion object { + const val SPAN_LIMIT = 100 // heuristics + } +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EmojiKeyboard.java b/app/src/main/java/com/keylesspalace/tusky/view/EmojiKeyboard.java new file mode 100644 index 0000000..501e02c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/EmojiKeyboard.java @@ -0,0 +1,153 @@ +package com.keylesspalace.tusky.view; + +import android.view.*; +import android.content.*; +import android.util.*; +import android.widget.*; +import android.app.*; +import android.text.*; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import androidx.annotation.NonNull; +import androidx.viewpager2.widget.ViewPager2; +import androidx.recyclerview.widget.RecyclerView; +import androidx.preference.PreferenceManager; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.StickerAdapter; +import com.keylesspalace.tusky.adapter.UnicodeEmojiAdapter; +import com.keylesspalace.tusky.entity.StickerPack; + +import java.util.*; + +public class EmojiKeyboard extends LinearLayout { + private TabLayout tabs; + private ViewPager2 pager; + private TabLayoutMediator currentMediator; + private String preferenceKey; + private SharedPreferences pref; + private Set recents; + private String RECENTS_DELIM = "; "; + private int MAX_RECENTS_ITEMS = 50; + private RecyclerView.Adapter adapter; + public boolean isSticky = false; // TODO + + public EmojiKeyboard(Context context) { + super(context); + init(context); + } + + public EmojiKeyboard(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public EmojiKeyboard(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + void init(Context context) { + inflate(context, R.layout.item_emoji_picker, this); + + pref = PreferenceManager.getDefaultSharedPreferences(context); + tabs = findViewById(R.id.picker_tabs); + pager = findViewById(R.id.picker_pager); + } + + public static final int UNICODE_MODE = 0; + public static final int CUSTOM_MODE = 1; + public static final int STICKER_MODE = 2; + + private void setupKeyboardWithAdapter(RecyclerView.Adapter adapter, String preferenceKey) { + this.preferenceKey = preferenceKey; + this.adapter = adapter; + + List list = Arrays.asList(pref.getString(preferenceKey, "").split(RECENTS_DELIM)); + recents = new LinkedHashSet(list); + ((EmojiKeyboardAdapter)adapter).onRecentsUpdate(recents); + + pager.setAdapter(adapter); + + if(currentMediator != null) + currentMediator.detach(); + + currentMediator = new TabLayoutMediator(tabs, pager, (TabLayoutMediator.TabConfigurationStrategy)adapter); + currentMediator.attach(); + } + + public void setupStickerKeyboard(OnEmojiSelectedListener listener, StickerPack packs[]) { + MAX_RECENTS_ITEMS = 20; + setupKeyboardWithAdapter(new StickerAdapter(packs, (_id, _emoji) -> { + this.appendToRecents(_emoji); + listener.onEmojiSelected(_id, _emoji); + }), "STICKER_RECENTS"); + } + + public void setupKeyboard(String id, int mode, OnEmojiSelectedListener listener) { + switch(mode) { + // WOOOPS, I forgot that I need to pass data to adapter + // For stickers, use SetupStickerKeyboard instead + // For custom emoji, use TODO + case CUSTOM_MODE: + case STICKER_MODE: + throw new IllegalArgumentException(); + default: + case UNICODE_MODE: + setupKeyboardWithAdapter(new UnicodeEmojiAdapter(id, (_id, _emoji) -> { + this.appendToRecents(_emoji); + listener.onEmojiSelected(_id, _emoji); + }), "UNICODE_RECENTS"); + } + } + + private void appendToRecents(String id) { + recents.remove(id); + recents.add(id); + int size = recents.size(); + String joined; + final SharedPreferences.Editor editor = pref.edit(); + if(size > MAX_RECENTS_ITEMS) { + List list = new ArrayList(recents); + list = list.subList(size - MAX_RECENTS_ITEMS, size); + joined = TextUtils.join(RECENTS_DELIM, list); + if(isSticky) { + recents = new LinkedHashSet(list); + } + } else { + joined = TextUtils.join(RECENTS_DELIM, recents); + } + + editor.putString(preferenceKey, joined); + editor.apply(); + + // no point on updating view if we are will be closed + if(isSticky) { + ((EmojiKeyboardAdapter)adapter).onRecentsUpdate(recents); + } + } + + public interface OnEmojiSelectedListener { + void onEmojiSelected(@NonNull String id, @NonNull String emoji); + } + + public interface EmojiKeyboardAdapter { + void onRecentsUpdate(@NonNull Set set); + } + + public static void show(Context ctx, String id, int mode, OnEmojiSelectedListener listener) { + final Dialog dialog = new Dialog(ctx); + + dialog.setTitle(null); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + dialog.setContentView(R.layout.dialog_emoji_keyboard); + EmojiKeyboard kbd = (EmojiKeyboard)dialog.findViewById(R.id.dialog_emoji_keyboard); + kbd.setupKeyboard(id, mode, (_id, _emoji) -> { + listener.onEmojiSelected(_id, _emoji); + if(!kbd.isSticky) + dialog.dismiss(); + }); + + dialog.show(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt b/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt new file mode 100644 index 0000000..09e648a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt @@ -0,0 +1,17 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class EmojiPicker @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : RecyclerView(context, attrs) { + + init { + clipToPadding = false + layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java b/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java new file mode 100644 index 0000000..50f9ea6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java @@ -0,0 +1,54 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListener { + private static final int VISIBLE_THRESHOLD = 15; + private int previousTotalItemCount; + private LinearLayoutManager layoutManager; + + public EndlessOnScrollListener(LinearLayoutManager layoutManager) { + this.layoutManager = layoutManager; + previousTotalItemCount = 0; + } + + @Override + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + int totalItemCount = layoutManager.getItemCount(); + int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); + if (totalItemCount < previousTotalItemCount) { + previousTotalItemCount = totalItemCount; + + } + if (totalItemCount != previousTotalItemCount) { + previousTotalItemCount = totalItemCount; + } + + if (lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) { + onLoadMore(totalItemCount, view); + } + } + + public void reset() { + previousTotalItemCount = 0; + } + + public abstract void onLoadMore(int totalItemsCount, RecyclerView view); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt new file mode 100644 index 0000000..ec748e0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt @@ -0,0 +1,33 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.VideoView + +class ExposedPlayPauseVideoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) + : VideoView(context, attrs, defStyleAttr) { + + private var listener: PlayPauseListener? = null + + fun setPlayPauseListener(listener: PlayPauseListener) { + this.listener = listener + } + + override fun start() { + super.start() + listener?.onPlay() + } + + override fun pause() { + super.pause() + listener?.onPause() + } + + interface PlayPauseListener { + fun onPlay() + fun onPause() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt new file mode 100644 index 0000000..2c73cd5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -0,0 +1,58 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import com.google.android.material.card.MaterialCardView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import kotlinx.android.synthetic.main.card_license.view.* + +class LicenseCard +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MaterialCardView(context, attrs, defStyleAttr) { + + init { + inflate(context, R.layout.card_license, this) + + setCardBackgroundColor(ThemeUtils.getColor(context, R.attr.colorSurface)) + + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.LicenseCard, 0, 0) + + val name: String? = a.getString(R.styleable.LicenseCard_name) + val license: String? = a.getString(R.styleable.LicenseCard_license) + val link: String? = a.getString(R.styleable.LicenseCard_link) + a.recycle() + + licenseCardName.text = name + licenseCardLicense.text = license + if(link.isNullOrBlank()) { + licenseCardLink.hide() + } else { + licenseCardLink.text = link + setOnClickListener { LinkHelper.openLink(link, context) } + } + + } + +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt new file mode 100644 index 0000000..42bfc27 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -0,0 +1,129 @@ +/* Copyright 2018 Jochem Raat + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.keylesspalace.tusky.entity.Attachment + +import com.keylesspalace.tusky.util.FocalPointUtil + +/** + * This is an extension of the standard android ImageView, which makes sure to update the custom + * matrix when its size changes if a focal point is set. + * + * If a focal point is set on this view, it will use the FocalPointUtil to update the image + * matrix each time the size of the view is changed. This is needed to ensure that the correct + * cropping is maintained. + * + * However if there is no focal point set (e.g. it is null), then this view should simply + * act exactly the same as an ordinary android ImageView. + */ +class MediaPreviewImageView +@JvmOverloads constructor( +context: Context, +attrs: AttributeSet? = null, +defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr),RequestListener { + private var focus: Attachment.Focus? = null + private var focalMatrix: Matrix? = null + + /** + * Set the focal point for this view. + */ + fun setFocalPoint(focus: Attachment.Focus?) { + this.focus = focus + super.setScaleType(ScaleType.MATRIX) + + if (focalMatrix == null) { + focalMatrix = Matrix() + } + } + + /** + * Remove the focal point from this view (if there was one). + */ + fun removeFocalPoint() { + super.setScaleType(ScaleType.CENTER_CROP) + focus = null + } + + /** + * Overridden getScaleType method which returns CENTER_CROP if we have a focal point set. + * + * This is necessary because the Android transitions framework tries to copy the scale type + * from this view to the PhotoView when animating between this view and the detailled view of + * the image. Since the PhotoView does not support a MATRIX scale type, the app would crash + * if we simply passed that on, so instead we pretend that CENTER_CROP is still used here + * even if we have a focus point set. + */ + override fun getScaleType(): ScaleType { + return if (focus != null) { + ScaleType.CENTER_CROP + } else { + super.getScaleType() + } + } + + /** + * Overridden setScaleType method which only accepts the new type if we don't have a focal + * point set. + * + */ + override fun setScaleType(type: ScaleType) { + if (focus != null) { + super.setScaleType(ScaleType.MATRIX) + } else { + super.setScaleType(type) + } + } + + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + return false + } + + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + recalculateMatrix(width, height, resource) + return false + } + + + /** + * Called when the size of the view changes, it calls the FocalPointUtil to update the + * matrix if we have a set focal point. It then reassigns the matrix to this imageView. + */ + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + recalculateMatrix(width, height, drawable) + + super.onSizeChanged(width, height, oldWidth, oldHeight) + } + + private fun recalculateMatrix(width: Int, height: Int, drawable: Drawable?) { + if (drawable != null && focus != null && focalMatrix != null) { + scaleType = ScaleType.MATRIX + FocalPointUtil.updateFocalPointMatrix(width.toFloat(), height.toFloat(), + drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat(), + focus as Attachment.Focus, focalMatrix as Matrix) + imageMatrix = focalMatrix + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt new file mode 100644 index 0000000..2cf8ad6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt @@ -0,0 +1,32 @@ +@file:JvmName("MuteAccountDialog") + +package com.keylesspalace.tusky.view + +import android.app.Activity +import android.widget.CheckBox +import android.widget.Spinner +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.R + +fun showMuteAccountDialog( + activity: Activity, + accountUsername: String, + onOk: (notifications: Boolean, duration: Int) -> Unit +) { + val view = activity.layoutInflater.inflate(R.layout.dialog_mute_account, null) + (view.findViewById(R.id.warning) as TextView).text = + activity.getString(R.string.dialog_mute_warning, accountUsername) + val checkbox: CheckBox = view.findViewById(R.id.checkbox) + checkbox.setChecked(true) + + AlertDialog.Builder(activity) + .setView(view) + .setPositiveButton(android.R.string.ok) { _, _ -> + val spinner: Spinner = view.findViewById(R.id.duration) + val durationValues = activity.resources.getIntArray(R.array.mute_duration_values) + onOk(checkbox.isChecked, durationValues[spinner.selectedItemPosition]) + } + .setNegativeButton(android.R.string.cancel, null) + .show() +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt new file mode 100644 index 0000000..d0e7305 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt @@ -0,0 +1,24 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import androidx.appcompat.widget.AppCompatImageView +import android.util.AttributeSet + +/** + * Created by charlag on 26/10/2017. + */ + +class SquareImageView : AppCompatImageView { + constructor(context: Context) : super(context) + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) + + constructor(context: Context, attributes: AttributeSet, defStyleAttr: Int) + : super(context, attributes, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = measuredWidth + setMeasuredDimension(width, width) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/StatusView.kt b/app/src/main/java/com/keylesspalace/tusky/view/StatusView.kt new file mode 100644 index 0000000..0b3d1a4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/StatusView.kt @@ -0,0 +1,75 @@ +package com.keylesspalace.tusky.view + +import android.view.* +import android.content.* +import android.util.* +import android.widget.* +import android.app.* +import android.text.* +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator + +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.preference.PreferenceManager + +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.ViewDataUtils +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.PrefKeys + +import java.util.*; + +class StatusView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) + : ConstraintLayout(context, attrs, defStyleAttr) { + + private var viewHolder : StatusViewHolder + private var statusDisplayOptions : StatusDisplayOptions + init { + View.inflate(context, R.layout.item_status, this) + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = false, + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + renderStatusAsMention = preferences.getBoolean(PrefKeys.RENDER_STATUS_AS_MENTION, true), + hideStats = false + ) + viewHolder = StatusViewHolder(this) + } + + fun setupWithStatus(status: Status) { + val concrete = ViewDataUtils.statusToViewData(status, false, false) + viewHolder.setupWithStatus(concrete, DummyStatusActionListener(), statusDisplayOptions) + } + + class DummyStatusActionListener: StatusActionListener { + override fun onReply(position: Int) { } + override fun onReblog(reblog: Boolean, position: Int) { } + override fun onFavourite(favourite: Boolean, position: Int) { } + override fun onBookmark(bookmark: Boolean, position: Int) { } + override fun onMore(view: View, position: Int) { } + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { } + override fun onViewThread(position: Int) { } + override fun onViewReplyTo(position: Int) { } + override fun onOpenReblog(position: Int) { } + override fun onExpandedChange(expanded: Boolean, position: Int) { } + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { } + override fun onLoadMore(position: Int) { } + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { } + override fun onVoteInPoll(position: Int, choices: MutableList) { } + override fun onViewAccount(id: String) { } + override fun onViewTag(id: String) { } + override fun onViewUrl(id: String) { } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt new file mode 100644 index 0000000..6bf7103 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -0,0 +1,30 @@ +package com.keylesspalace.tusky.viewdata + +import android.os.Parcelable +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class AttachmentViewData( + val attachment: Attachment, + val statusId: String?, + val statusUrl: String? +) : Parcelable { + companion object { + @JvmStatic + fun list(status: Status): List { + val actionable = status.actionableStatus + return actionable.attachments.map { + AttachmentViewData(it, actionable.id, actionable.url!!) + } + } + + fun list(attachments: List): List { + return attachments.map { + AttachmentViewData(it, it.id, it.url) + } + } + + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/ChatViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/ChatViewData.kt new file mode 100644 index 0000000..2ebbb61 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/ChatViewData.kt @@ -0,0 +1,135 @@ +package com.keylesspalace.tusky.viewdata + +import android.text.Spanned +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card +import com.keylesspalace.tusky.entity.Emoji +import java.util.* + + +abstract class ChatViewData { + abstract fun getViewDataId() : Int + abstract fun deepEquals(o: ChatViewData) : Boolean + + class Concrete(val account : Account, + val id: String, + val unread: Long, + val lastMessage: ChatMessageViewData.Concrete?, + val updatedAt: Date ) : ChatViewData() { + override fun getViewDataId(): Int { + return id.hashCode() + } + + override fun deepEquals(o: ChatViewData): Boolean { + if (o !is Concrete) return false + return Objects.equals(o.account, account) + && Objects.equals(o.id, id) + && o.unread == unread + && (lastMessage == o.lastMessage || (lastMessage != null && o.lastMessage != null && o.lastMessage.deepEquals(lastMessage))) + && Objects.equals(o.updatedAt, updatedAt) + } + + override fun hashCode(): Int { + return Objects.hash(account, id, unread, lastMessage, updatedAt) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + return deepEquals(other as Concrete) + } + } + + class Placeholder(val id: String, val isLoading: Boolean) : ChatViewData() { + override fun getViewDataId(): Int { + return id.hashCode() + } + + override fun deepEquals(o: ChatViewData): Boolean { + if( o !is Placeholder ) return false + return o.isLoading == isLoading && o.id == id + } + + override fun hashCode(): Int { + var result = if (isLoading) 1 else 0 + result = 31 * result + id.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + return deepEquals(other as Placeholder) + } + } +} + +abstract class ChatMessageViewData { + abstract fun getViewDataId() : Int + abstract fun deepEquals(o: ChatMessageViewData) : Boolean + + class Concrete(val id: String, + val content: Spanned?, + val chatId: String, + val accountId: String, + val createdAt: Date, + val attachment: Attachment?, + val emojis: List, + val card: Card?) : ChatMessageViewData() + { + override fun getViewDataId(): Int { + return id.hashCode() + } + + override fun deepEquals(o: ChatMessageViewData): Boolean { + if( o !is Concrete ) return false + + return Objects.equals(o.id, id) + && Objects.equals(o.content, content) + && Objects.equals(o.chatId, chatId) + && Objects.equals(o.accountId, accountId) + && Objects.equals(o.createdAt, createdAt) + && Objects.equals(o.attachment, attachment) + && Objects.equals(o.emojis, emojis) + && Objects.equals(o.card, card) + } + + override fun hashCode() : Int { + return Objects.hash(id, content, chatId, accountId, createdAt, attachment, card) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + return deepEquals(other as Concrete) + } + } + + class Placeholder(val id: String, val isLoading: Boolean) : ChatMessageViewData() { + override fun getViewDataId(): Int { + return id.hashCode() + } + + override fun deepEquals(o: ChatMessageViewData): Boolean { + if( o !is Placeholder) return false + return o.isLoading == isLoading && o.id == id + } + + override fun hashCode(): Int { + var result = if (isLoading) 1 else 0 + result = 31 * result + id.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + return deepEquals(other as Placeholder) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java new file mode 100644 index 0000000..845ecc2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -0,0 +1,144 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata; + +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Notification; + +import java.util.Objects; + +import io.reactivex.annotations.Nullable; + +/** + * Created by charlag on 12/07/2017. + *

+ * Class to represent data required to display either a notification or a placeholder. + * It is either a {@link Placeholder} or a {@link Concrete}. + * It is modelled this way because close relationship between placeholder and concrete notification + * is fine in this case. Placeholder case is not modelled as a type of notification because + * invariants would be violated and because it would model domain incorrectly. It is prefereable to + * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and + * more native. + */ +public abstract class NotificationViewData { + private NotificationViewData() { + } + + public abstract long getViewDataId(); + + public abstract boolean deepEquals(NotificationViewData other); + + public static final class Concrete extends NotificationViewData { + private final Notification.Type type; + private final String id; + private final Account account; + @Nullable + private final StatusViewData.Concrete statusViewData; + @Nullable + private final String emoji; + @Nullable + private final Account target; // move notification + + public Concrete(Notification.Type type, String id, Account account, + @Nullable StatusViewData.Concrete statusViewData, + @Nullable String emoji, @Nullable Account target) { + this.type = type; + this.id = id; + this.account = account; + this.statusViewData = statusViewData; + this.emoji = emoji; + this.target = target; + } + + public Notification.Type getType() { + return type; + } + + public String getId() { + return id; + } + + public Account getAccount() { + return account; + } + + @Nullable + public StatusViewData.Concrete getStatusViewData() { + return statusViewData; + } + + @Nullable + public String getEmoji() { + return emoji; + } + + @Nullable + public Account getTarget() { + return target; + } + + @Override + public long getViewDataId() { + return id.hashCode(); + } + + @Override + public boolean deepEquals(NotificationViewData o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Concrete concrete = (Concrete) o; + return type == concrete.type && + Objects.equals(id, concrete.id) && + account.getId().equals(concrete.account.getId()) && + (emoji != null && concrete.emoji != null && emoji.equals(concrete.emoji)) && + (target != null && concrete.target != null && target.getId().equals(concrete.target.getId())) && + (statusViewData == concrete.statusViewData || + statusViewData != null && + statusViewData.deepEquals(concrete.statusViewData)); + } + + @Override + public int hashCode() { + return Objects.hash(type, id, account, statusViewData); + } + } + + public static final class Placeholder extends NotificationViewData { + private final long id; + private final boolean isLoading; + + public Placeholder(long id, boolean isLoading) { + this.id = id; + this.isLoading = isLoading; + } + + public boolean isLoading() { + return isLoading; + } + + @Override + public long getViewDataId() { + return id; + } + + @Override + public boolean deepEquals(NotificationViewData other) { + if (!(other instanceof Placeholder)) return false; + Placeholder that = (Placeholder) other; + return isLoading == that.isLoading && id == that.id; + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt new file mode 100644 index 0000000..f0ca626 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -0,0 +1,80 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata + +import android.content.Context +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.core.text.parseAsHtml +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PollOption +import java.util.* +import kotlin.math.roundToInt + +data class PollViewData( + val id: String, + val expiresAt: Date?, + val expired: Boolean, + val multiple: Boolean, + val votesCount: Int, + val votersCount: Int?, + val options: List, + var voted: Boolean +) + +data class PollOptionViewData( + val title: String, + var votesCount: Int, + var selected: Boolean +) + +fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { + return if (fraction == 0) { + 0 + } else { + val total = totalVoters ?: totalVotes + (fraction / total.toDouble() * 100).roundToInt() + } +} + +fun buildDescription(title: String, percent: Int, context: Context): Spanned { + return SpannableStringBuilder(context.getString(R.string.poll_percent_format, percent).parseAsHtml()) + .append(" ") + .append(title) +} + +fun Poll?.toViewData(): PollViewData? { + if (this == null) return null + return PollViewData( + id, + expiresAt, + expired, + multiple, + votesCount, + votersCount, + options.map { it.toViewData() }, + voted + ) +} + +fun PollOption.toViewData(): PollOptionViewData { + return PollOptionViewData( + title, + votesCount, + false + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java new file mode 100644 index 0000000..b9126b8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -0,0 +1,765 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata; + +import android.os.Build; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import androidx.annotation.Nullable; + +import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.EmojiReaction; +import com.keylesspalace.tusky.entity.Poll; +import com.keylesspalace.tusky.entity.Status; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * Created by charlag on 11/07/2017. + *

+ * Class to represent data required to display either a notification or a placeholder. + * It is either a {@link StatusViewData.Concrete} or a {@link StatusViewData.Placeholder}. + */ + +public abstract class StatusViewData { + + private StatusViewData() { } + + public abstract long getViewDataId(); + + public abstract boolean deepEquals(StatusViewData other); + + public static final class Concrete extends StatusViewData { + private static final char SOFT_HYPHEN = '\u00ad'; + private static final char ASCII_HYPHEN = '-'; + + private final String id; + private final Spanned content; + final boolean reblogged; + final boolean favourited; + final boolean bookmarked; + @Nullable + private final String spoilerText; + private final Status.Visibility visibility; + private final List attachments; + @Nullable + private final String rebloggedByUsername; + @Nullable + private final String rebloggedAvatar; + private final boolean isSensitive; + final boolean isExpanded; + private final boolean isShowingContent; + private final String userFullName; + private final String nickname; + private final String avatar; + private final Date createdAt; + private final int reblogsCount; + private final int favouritesCount; + @Nullable + private final String inReplyToId; + @Nullable + private final String inReplyToAccountAcct; + // I would rather have something else but it would be too much of a rewrite + @Nullable + private final Status.Mention[] mentions; + private final String senderId; + private final boolean rebloggingEnabled; + private final Status.Application application; + private final List statusEmojis; + private final List accountEmojis; + private final List rebloggedByAccountEmojis; + @Nullable + private final Card card; + private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ + final boolean isCollapsed; /** Whether the status is shown partially or fully */ + @Nullable + private final PollViewData poll; + private final boolean isBot; + private final boolean isMuted; /* user toggle */ + private final boolean isThreadMuted; /* thread_muted state got from backend */ + private final boolean isUserMuted; /* muted state got from backend */ + private final int conversationId; + @Nullable + private final List emojiReactions; + private final boolean parentVisible; + + public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked, + @Nullable String spoilerText, Status.Visibility visibility, List attachments, + @Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded, + boolean isShowingContent, String userFullName, String nickname, String avatar, + Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, + @Nullable String inReplyToAccountAcct, @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, + Status.Application application, List statusEmojis, List accountEmojis, List rebloggedByAccountEmojis, @Nullable Card card, + boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot, boolean isMuted, boolean isThreadMuted, + boolean isUserMuted, int conversationId, @Nullable List emojiReactions, boolean parentVisible) { + + this.id = id; + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M) { + // https://github.com/tuskyapp/Tusky/issues/563 + this.content = replaceCrashingCharacters(content); + this.spoilerText = spoilerText == null ? null : replaceCrashingCharacters(spoilerText).toString(); + this.nickname = replaceCrashingCharacters(nickname).toString(); + } else { + this.content = content; + this.spoilerText = spoilerText; + this.nickname = nickname; + } + this.reblogged = reblogged; + this.favourited = favourited; + this.bookmarked = bookmarked; + this.visibility = visibility; + this.attachments = attachments; + this.rebloggedByUsername = rebloggedByUsername; + this.rebloggedAvatar = rebloggedAvatar; + this.isSensitive = sensitive; + this.isExpanded = isExpanded; + this.isShowingContent = isShowingContent; + this.userFullName = userFullName; + this.avatar = avatar; + this.createdAt = createdAt; + this.reblogsCount = reblogsCount; + this.favouritesCount = favouritesCount; + this.inReplyToId = inReplyToId; + this.inReplyToAccountAcct = inReplyToAccountAcct; + this.mentions = mentions; + this.senderId = senderId; + this.rebloggingEnabled = rebloggingEnabled; + this.application = application; + this.statusEmojis = statusEmojis; + this.accountEmojis = accountEmojis; + this.rebloggedByAccountEmojis = rebloggedByAccountEmojis; + this.card = card; + this.isCollapsible = isCollapsible; + this.isCollapsed = isCollapsed; + this.poll = poll; + this.isBot = isBot; + this.isMuted = isMuted; + this.isThreadMuted = isThreadMuted; + this.isUserMuted = isUserMuted; + this.conversationId = conversationId; + this.emojiReactions = emojiReactions; + this.parentVisible = parentVisible; + } + + public String getId() { + return id; + } + + public Spanned getContent() { + return content; + } + + public boolean isReblogged() { + return reblogged; + } + + public boolean isFavourited() { + return favourited; + } + + public boolean isBookmarked() { + return bookmarked; + } + + @Nullable + public String getSpoilerText() { + return spoilerText; + } + + public Status.Visibility getVisibility() { + return visibility; + } + + public List getAttachments() { + return attachments; + } + + @Nullable + public String getRebloggedByUsername() { + return rebloggedByUsername; + } + + public boolean isSensitive() { + return isSensitive; + } + + public boolean isExpanded() { + return isExpanded; + } + + public boolean isShowingContent() { + return isShowingContent; + } + + public boolean isBot(){ return isBot; } + + @Nullable + public String getRebloggedAvatar() { + return rebloggedAvatar; + } + + public String getUserFullName() { + return userFullName; + } + + public String getNickname() { + return nickname; + } + + public String getAvatar() { + return avatar; + } + + public Date getCreatedAt() { + return createdAt; + } + + public int getReblogsCount() { + return reblogsCount; + } + + public int getFavouritesCount() { + return favouritesCount; + } + + @Nullable + public String getInReplyToId() { + return inReplyToId; + } + + public String getInReplyToAccountAcct() { + if(inReplyToAccountAcct != null) { + return inReplyToAccountAcct; + } + return ""; + } + + public String getSenderId() { + return senderId; + } + + public Boolean getRebloggingEnabled() { + return rebloggingEnabled; + } + + @Nullable + public Status.Mention[] getMentions() { + return mentions; + } + + public Status.Application getApplication() { + return application; + } + + public List getStatusEmojis() { + return statusEmojis; + } + + public List getAccountEmojis() { + return accountEmojis; + } + + public boolean getParentVisible() { + return parentVisible; + } + + public List getRebloggedByAccountEmojis() { + return rebloggedByAccountEmojis; + } + + @Nullable + public Card getCard() { + return card; + } + + /** + * Specifies whether the content of this post is allowed to be collapsed or if it should show + * all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + public boolean isCollapsible() { + return isCollapsible; + } + + /** + * Specifies whether the content of this post is currently limited in visibility to the first + * 500 characters or not. + * + * @return Whether the post is collapsed or fully expanded. + */ + public boolean isCollapsed() { + return isCollapsed; + } + + @Nullable + public PollViewData getPoll() { + return poll; + } + + @Override public long getViewDataId() { + // Chance of collision is super low and impact of mistake is low as well + return id.hashCode(); + } + + public boolean isThreadMuted() { + return isThreadMuted; + } + + public boolean isMuted() { + return isMuted; + } + + public boolean isUserMuted() { + return isUserMuted; + } + + @Nullable + public List getEmojiReactions() { + return emojiReactions; + } + + public boolean deepEquals(StatusViewData o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Concrete concrete = (Concrete) o; + return reblogged == concrete.reblogged && + favourited == concrete.favourited && + bookmarked == concrete.bookmarked && + isSensitive == concrete.isSensitive && + isExpanded == concrete.isExpanded && + isShowingContent == concrete.isShowingContent && + isBot == concrete.isBot && + reblogsCount == concrete.reblogsCount && + favouritesCount == concrete.favouritesCount && + rebloggingEnabled == concrete.rebloggingEnabled && + Objects.equals(id, concrete.id) && + Objects.equals(content, concrete.content) && + Objects.equals(spoilerText, concrete.spoilerText) && + visibility == concrete.visibility && + Objects.equals(attachments, concrete.attachments) && + Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) && + Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) && + Objects.equals(userFullName, concrete.userFullName) && + Objects.equals(nickname, concrete.nickname) && + Objects.equals(avatar, concrete.avatar) && + Objects.equals(createdAt, concrete.createdAt) && + Objects.equals(inReplyToId, concrete.inReplyToId) && + Objects.equals(inReplyToAccountAcct, concrete.inReplyToAccountAcct) && + Arrays.equals(mentions, concrete.mentions) && + Objects.equals(senderId, concrete.senderId) && + Objects.equals(application, concrete.application) && + Objects.equals(statusEmojis, concrete.statusEmojis) && + Objects.equals(accountEmojis, concrete.accountEmojis) && + Objects.equals(rebloggedByAccountEmojis, concrete.rebloggedByAccountEmojis) && + Objects.equals(card, concrete.card) && + Objects.equals(poll, concrete.poll) && + isCollapsed == concrete.isCollapsed && + isMuted == concrete.isMuted && + isThreadMuted == concrete.isThreadMuted && + isUserMuted == concrete.isUserMuted && + conversationId == concrete.conversationId && + Objects.equals(emojiReactions, concrete.emojiReactions) && + parentVisible == concrete.parentVisible; + } + + static Spanned replaceCrashingCharacters(Spanned content) { + return (Spanned) replaceCrashingCharacters((CharSequence) content); + } + + static CharSequence replaceCrashingCharacters(CharSequence content) { + boolean replacing = false; + SpannableStringBuilder builder = null; + int length = content.length(); + + for (int index = 0; index < length; ++index) { + char character = content.charAt(index); + + // If there are more than one or two, switch to a map + if (character == SOFT_HYPHEN) { + if (!replacing) { + replacing = true; + builder = new SpannableStringBuilder(content, 0, index); + } + builder.append(ASCII_HYPHEN); + } else if (replacing) { + builder.append(character); + } + } + + return replacing ? builder : content; + } + } + + public static final class Placeholder extends StatusViewData { + private final boolean isLoading; + private final String id; + + public Placeholder(String id, boolean isLoading) { + this.id = id; + this.isLoading = isLoading; + } + + public boolean isLoading() { + return isLoading; + } + + public String getId() { + return id; + } + + @Override public long getViewDataId() { + return id.hashCode(); + } + + @Override public boolean deepEquals(StatusViewData other) { + if (!(other instanceof Placeholder)) return false; + Placeholder that = (Placeholder) other; + return isLoading == that.isLoading && id.equals(that.id); + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Placeholder that = (Placeholder) o; + + return deepEquals(that); + } + + @Override + public int hashCode() { + int result = (isLoading ? 1 : 0); + result = 31 * result + id.hashCode(); + return result; + } + } + + public static class Builder { + private String id; + private Spanned content; + private boolean reblogged; + private boolean favourited; + private boolean bookmarked; + private String spoilerText; + private Status.Visibility visibility; + private List attachments; + private String rebloggedByUsername; + private String rebloggedAvatar; + private boolean isSensitive; + private boolean isExpanded; + private boolean isShowingContent; + private String userFullName; + private String nickname; + private String avatar; + private Date createdAt; + private int reblogsCount; + private int favouritesCount; + private String inReplyToId; + private String inReplyToAccountAcct; + private Status.Mention[] mentions; + private String senderId; + private boolean rebloggingEnabled; + private Status.Application application; + private List statusEmojis; + private List accountEmojis; + private List rebloggedByAccountEmojis; + private Card card; + private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */ + private boolean isCollapsed; /** Whether the status is shown partially or fully */ + private PollViewData poll; + private boolean isBot; + private boolean isMuted; + private boolean isThreadMuted; + private boolean isUserMuted; + private int conversationId; + private List emojiReactions; + private boolean parentVisible; + + public Builder() { + } + + public Builder(final StatusViewData.Concrete viewData) { + id = viewData.id; + content = viewData.content; + reblogged = viewData.reblogged; + favourited = viewData.favourited; + bookmarked = viewData.bookmarked; + spoilerText = viewData.spoilerText; + visibility = viewData.visibility; + attachments = viewData.attachments == null ? null : new ArrayList<>(viewData.attachments); + rebloggedByUsername = viewData.rebloggedByUsername; + rebloggedAvatar = viewData.rebloggedAvatar; + isSensitive = viewData.isSensitive; + isExpanded = viewData.isExpanded; + isShowingContent = viewData.isShowingContent; + userFullName = viewData.userFullName; + nickname = viewData.nickname; + avatar = viewData.avatar; + createdAt = new Date(viewData.createdAt.getTime()); + reblogsCount = viewData.reblogsCount; + favouritesCount = viewData.favouritesCount; + inReplyToId = viewData.inReplyToId; + inReplyToAccountAcct = viewData.inReplyToAccountAcct; + mentions = viewData.mentions == null ? null : viewData.mentions.clone(); + senderId = viewData.senderId; + rebloggingEnabled = viewData.rebloggingEnabled; + application = viewData.application; + statusEmojis = viewData.getStatusEmojis(); + accountEmojis = viewData.getAccountEmojis(); + card = viewData.getCard(); + isCollapsible = viewData.isCollapsible(); + isCollapsed = viewData.isCollapsed(); + poll = viewData.poll; + isBot = viewData.isBot(); + isMuted = viewData.isMuted; + isThreadMuted = viewData.isThreadMuted; + isUserMuted = viewData.isUserMuted; + emojiReactions = viewData.emojiReactions; + parentVisible = viewData.parentVisible; + } + + public Builder setId(String id) { + this.id = id; + return this; + } + + public Builder setContent(Spanned content) { + this.content = content; + return this; + } + + public Builder setReblogged(boolean reblogged) { + this.reblogged = reblogged; + return this; + } + + public Builder setFavourited(boolean favourited) { + this.favourited = favourited; + return this; + } + + public Builder setBookmarked(boolean bookmarked) { + this.bookmarked = bookmarked; + return this; + } + + public Builder setSpoilerText(String spoilerText) { + this.spoilerText = spoilerText; + return this; + } + + public Builder setVisibility(Status.Visibility visibility) { + this.visibility = visibility; + return this; + } + + public Builder setAttachments(List attachments) { + this.attachments = attachments; + return this; + } + + public Builder setRebloggedByUsername(String rebloggedByUsername) { + this.rebloggedByUsername = rebloggedByUsername; + return this; + } + + public Builder setRebloggedAvatar(String rebloggedAvatar) { + this.rebloggedAvatar = rebloggedAvatar; + return this; + } + + public Builder setSensitive(boolean sensitive) { + this.isSensitive = sensitive; + return this; + } + + public Builder setIsExpanded(boolean isExpanded) { + this.isExpanded = isExpanded; + return this; + } + + public Builder setIsShowingSensitiveContent(boolean isShowingSensitiveContent) { + this.isShowingContent = isShowingSensitiveContent; + return this; + } + + public Builder setIsBot(boolean isBot) { + this.isBot = isBot; + return this; + } + + public Builder setUserFullName(String userFullName) { + this.userFullName = userFullName; + return this; + } + + public Builder setNickname(String nickname) { + this.nickname = nickname; + return this; + } + + public Builder setAvatar(String avatar) { + this.avatar = avatar; + return this; + } + + public Builder setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + return this; + } + + public Builder setReblogsCount(int reblogsCount) { + this.reblogsCount = reblogsCount; + return this; + } + + public Builder setFavouritesCount(int favouritesCount) { + this.favouritesCount = favouritesCount; + return this; + } + + public Builder setInReplyToId(String inReplyToId) { + this.inReplyToId = inReplyToId; + return this; + } + + public Builder setInReplyToAccountAcct(String inReplyToAccountAcct) { + this.inReplyToAccountAcct = inReplyToAccountAcct; + return this; + } + + public Builder setMentions(Status.Mention[] mentions) { + this.mentions = mentions; + return this; + } + + public Builder setSenderId(String senderId) { + this.senderId = senderId; + return this; + } + + public Builder setRebloggingEnabled(boolean rebloggingEnabled) { + this.rebloggingEnabled = rebloggingEnabled; + return this; + } + + public Builder setApplication(Status.Application application) { + this.application = application; + return this; + } + + public Builder setStatusEmojis(List emojis) { + this.statusEmojis = emojis; + return this; + } + + public Builder setAccountEmojis(List emojis) { + this.accountEmojis = emojis; + return this; + } + + public Builder setParentVisible(boolean parentVisible) { + this.parentVisible = parentVisible; + return this; + } + + public Builder setRebloggedByEmojis(List emojis) { + this.rebloggedByAccountEmojis = emojis; + return this; + } + + public Builder setCard(Card card) { + this.card = card; + return this; + } + + /** + * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to support collapsing + * its content limiting the visible length when collapsed at 500 characters, + * + * @param collapsible Whether the status should support being collapsed or not. + * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. + */ + public Builder setCollapsible(boolean collapsible) { + isCollapsible = collapsible; + return this; + } + + /** + * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed + * state, hiding partially the content of the post if it exceeds a certain amount of characters. + * + * @param collapsed Whether to show the full content of the status or not. + * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. + */ + public Builder setCollapsed(boolean collapsed) { + isCollapsed = collapsed; + return this; + } + + public Builder setPoll(Poll poll) { + this.poll = PollViewDataKt.toViewData(poll); + return this; + } + + public Builder setMuted(Boolean isMuted) { + this.isMuted = isMuted; + return this; + } + + public Builder setUserMuted(Boolean isUserMuted) { + this.isUserMuted = isUserMuted; + return this; + } + + public Builder setThreadMuted(Boolean isThreadMuted) { + this.isThreadMuted = isThreadMuted; + return this; + } + + public Builder setConversationId(int conversationId) { + this.conversationId = conversationId; + return this; + } + + public Builder setEmojiReactions(List emojiReactions) { + this.emojiReactions = emojiReactions; + return this; + } + + public StatusViewData.Concrete createStatusViewData() { + if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); + if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); + if (this.createdAt == null) createdAt = new Date(); + + return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, spoilerText, + visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, + isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, + favouritesCount, inReplyToId, inReplyToAccountAcct, mentions, senderId, rebloggingEnabled, application, + statusEmojis, accountEmojis, rebloggedByAccountEmojis, card, isCollapsible, isCollapsed, poll, isBot, isMuted, isThreadMuted, + isUserMuted, conversationId, emojiReactions, parentVisible); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt new file mode 100644 index 0000000..934d686 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -0,0 +1,313 @@ +package com.keylesspalace.tusky.viewmodel + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.Single +import io.reactivex.disposables.Disposable +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class AccountViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager +) : RxAwareViewModel() { + + val accountData = MutableLiveData>() + val relationshipData = MutableLiveData>() + + val noteSaved = MutableLiveData() + + private val identityProofData = MutableLiveData>() + + val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> + identityProofs.orEmpty().map { Either.Left(it) } + .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) + } + + val isRefreshing = MutableLiveData().apply { value = false } + private var isDataLoading = false + + lateinit var accountId: String + var isSelf = false + + private var noteDisposable: Disposable? = null + + init { + eventHub.events + .subscribe { event -> + if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) { + accountData.postValue(Success(event.newProfileData)) + } + }.autoDispose() + } + + private fun obtainAccount(reload: Boolean = false) { + if (accountData.value == null || reload) { + isDataLoading = true + accountData.postValue(Loading()) + + mastodonApi.account(accountId) + .subscribe({ account -> + accountData.postValue(Success(account)) + isDataLoading = false + isRefreshing.postValue(false) + }, {t -> + Log.w(TAG, "failed obtaining account", t) + accountData.postValue(Error()) + isDataLoading = false + isRefreshing.postValue(false) + }) + .autoDispose() + } + } + + private fun obtainRelationship(reload: Boolean = false) { + if (relationshipData.value == null || reload) { + + relationshipData.postValue(Loading()) + + mastodonApi.relationships(listOf(accountId)) + .subscribe({ relationships -> + relationshipData.postValue(Success(relationships[0])) + }, { t -> + Log.w(TAG, "failed obtaining relationships", t) + relationshipData.postValue(Error()) + }) + .autoDispose() + } + } + + private fun obtainIdentityProof(reload: Boolean = false) { + if (identityProofData.value == null || reload) { + + mastodonApi.identityProofs(accountId) + .subscribe({ proofs -> + identityProofData.postValue(proofs) + }, { t -> + Log.w(TAG, "failed obtaining identity proofs", t) + }) + .autoDispose() + } + } + + fun changeFollowState() { + val relationship = relationshipData.value?.data + if (relationship?.following == true || relationship?.requested == true) { + changeRelationship(RelationShipAction.UNFOLLOW) + } else { + changeRelationship(RelationShipAction.FOLLOW) + } + } + + fun changeBlockState() { + if (relationshipData.value?.data?.blocking == true) { + changeRelationship(RelationShipAction.UNBLOCK) + } else { + changeRelationship(RelationShipAction.BLOCK) + } + } + + fun muteAccount(notifications: Boolean, duration: Int) { + changeRelationship(RelationShipAction.MUTE, notifications, duration) + } + + fun unmuteAccount() { + changeRelationship(RelationShipAction.UNMUTE) + } + + fun changeSubscribingState() { + val relationship = relationshipData.value?.data + if(relationship?.notifying == true /* Mastodon 3.3.0rc1 */ + || relationship?.subscribing == true /* Pleroma */ ) { + changeRelationship(RelationShipAction.UNSUBSCRIBE) + } else { + changeRelationship(RelationShipAction.SUBSCRIBE) + } + } + + fun blockDomain(instance: String) { + mastodonApi.blockDomain(instance).enqueue(object: Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + eventHub.dispatch(DomainMuteEvent(instance)) + val relation = relationshipData.value?.data + if(relation != null) { + relationshipData.postValue(Success(relation.copy(blockingDomain = true))) + } + } else { + Log.e(TAG, "Error muting %s".format(instance)) + } + } + + override fun onFailure(call: Call, t: Throwable) { + Log.e(TAG, "Error muting %s".format(instance), t) + } + }) + } + + fun unblockDomain(instance: String) { + mastodonApi.unblockDomain(instance).enqueue(object: Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val relation = relationshipData.value?.data + if(relation != null) { + relationshipData.postValue(Success(relation.copy(blockingDomain = false))) + } + } else { + Log.e(TAG, "Error unmuting %s".format(instance)) + } + } + + override fun onFailure(call: Call, t: Throwable) { + Log.e(TAG, "Error unmuting %s".format(instance), t) + } + }) + } + + fun changeShowReblogsState() { + if (relationshipData.value?.data?.showingReblogs == true) { + changeRelationship(RelationShipAction.FOLLOW, false) + } else { + changeRelationship(RelationShipAction.FOLLOW, true) + } + } + + /** + * @param parameter showReblogs if RelationShipAction.FOLLOW, notifications if MUTE + */ + private fun changeRelationship(relationshipAction: RelationShipAction, parameter: Boolean? = null, duration: Int? = null) { + val relation = relationshipData.value?.data + val account = accountData.value?.data + val isMastodon = relationshipData.value?.data?.notifying != null + + if (relation != null && account != null) { + // optimistically post new state for faster response + + val newRelation = when (relationshipAction) { + RelationShipAction.FOLLOW -> { + if (account.locked) { + relation.copy(requested = true) + } else { + relation.copy(following = true) + } + } + RelationShipAction.UNFOLLOW -> relation.copy(following = false) + RelationShipAction.BLOCK -> relation.copy(blocking = true) + RelationShipAction.UNBLOCK -> relation.copy(blocking = false) + RelationShipAction.MUTE -> relation.copy(muting = true) + RelationShipAction.UNMUTE -> relation.copy(muting = false) + RelationShipAction.SUBSCRIBE -> { + if(isMastodon) + relation.copy(notifying = true) + else relation.copy(subscribing = true) + } + RelationShipAction.UNSUBSCRIBE -> { + if(isMastodon) + relation.copy(notifying = false) + else relation.copy(subscribing = false) + } + } + relationshipData.postValue(Loading(newRelation)) + } + + when (relationshipAction) { + RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, showReblogs = parameter ?: true) + RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) + RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) + RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) + RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true, duration) + RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) + RelationShipAction.SUBSCRIBE -> { + if(isMastodon) + mastodonApi.followAccount(accountId, notify = true) + else mastodonApi.subscribeAccount(accountId) + } + RelationShipAction.UNSUBSCRIBE -> { + if(isMastodon) + mastodonApi.followAccount(accountId, notify = false) + else mastodonApi.unsubscribeAccount(accountId) + } + }.subscribe( + { relationship -> + relationshipData.postValue(Success(relationship)) + + when (relationshipAction) { + RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) + RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) + RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId, true)) + RelationShipAction.UNMUTE -> eventHub.dispatch(MuteEvent(accountId, false)) + else -> { + } + } + }, + { + relationshipData.postValue(Error(relation)) + } + ) + .autoDispose() + } + + fun noteChanged(newNote: String) { + noteSaved.postValue(false) + noteDisposable?.dispose() + noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS) + .flatMap { + mastodonApi.updateAccountNote(accountId, newNote) + } + .doOnSuccess { + noteSaved.postValue(true) + } + .delay(4, TimeUnit.SECONDS) + .subscribe({ + noteSaved.postValue(false) + }, { + Log.e(TAG, "Error updating note", it) + }) + } + + override fun onCleared() { + super.onCleared() + noteDisposable?.dispose() + } + + fun refresh() { + reload(true) + } + + private fun reload(isReload: Boolean = false) { + if (isDataLoading) + return + accountId.let { + obtainAccount(isReload) + obtainIdentityProof() + if (!isSelf) + obtainRelationship(isReload) + } + } + + fun setAccountInfo(accountId: String) { + this.accountId = accountId + this.isSelf = accountManager.activeAccount?.accountId == accountId + reload(false) + } + + enum class RelationShipAction { + FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE + } + + companion object { + const val TAG = "AccountViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt new file mode 100644 index 0000000..1dc4122 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -0,0 +1,92 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.viewmodel + +import android.util.Log +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.Either.Left +import com.keylesspalace.tusky.util.Either.Right +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.withoutFirstWhich +import io.reactivex.Observable +import io.reactivex.subjects.BehaviorSubject +import javax.inject.Inject + +data class State(val accounts: Either>, val searchResult: List?) + +class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { + + val state: Observable get() = _state + private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null)) + + fun load(listId: String) { + val state = _state.value!! + if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) { + api.getAccountsInList(listId, 0).subscribe({ accounts -> + updateState { copy(accounts = Right(accounts)) } + }, { e -> + updateState { copy(accounts = Left(e)) } + }).autoDispose() + } + } + + fun addAccountToList(listId: String, account: Account) { + api.addCountToList(listId, listOf(account.id)) + .subscribe({ + updateState { + copy(accounts = accounts.map { it + account }) + } + }, { + Log.i(javaClass.simpleName, + "Failed to add account to the list: ${account.username}") + }) + .autoDispose() + } + + fun deleteAccountFromList(listId: String, accountId: String) { + api.deleteAccountFromList(listId, listOf(accountId)) + .subscribe({ + updateState { + copy(accounts = accounts.map { accounts -> + accounts.withoutFirstWhich { it.id == accountId } + }) + } + }, { + Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId") + }) + .autoDispose() + } + + fun search(query: String) { + when { + query.isEmpty() -> updateState { copy(searchResult = null) } + query.isBlank() -> updateState { copy(searchResult = listOf()) } + else -> api.searchAccounts(query, null, 10, true) + .subscribe({ result -> + updateState { copy(searchResult = result) } + }, { + updateState { copy(searchResult = listOf()) } + }).autoDispose() + } + } + + private inline fun updateState(crossinline fn: State.() -> State) { + _state.onNext(fn(_state.value!!)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt new file mode 100644 index 0000000..24a7339 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -0,0 +1,288 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import com.keylesspalace.tusky.EditProfileActivity.Companion.AVATAR_SIZE +import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_HEIGHT +import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_WIDTH +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.ProfileEditedEvent +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.StringField +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.Single +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import io.reactivex.schedulers.Schedulers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.MultipartBody +import okhttp3.RequestBody +import org.json.JSONException +import org.json.JSONObject +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.OutputStream +import javax.inject.Inject + +private const val HEADER_FILE_NAME = "header.png" +private const val AVATAR_FILE_NAME = "avatar.png" + +private const val TAG = "EditProfileViewModel" + +class EditProfileViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +): ViewModel() { + + val profileData = MutableLiveData>() + val avatarData = MutableLiveData>() + val headerData = MutableLiveData>() + val saveData = MutableLiveData>() + val instanceData = MutableLiveData>() + + private var oldProfileData: Account? = null + + private val disposeables = CompositeDisposable() + + fun obtainProfile() { + if(profileData.value == null || profileData.value is Error) { + + profileData.postValue(Loading()) + + mastodonApi.accountVerifyCredentials() + .subscribe( + {profile -> + oldProfileData = profile + profileData.postValue(Success(profile)) + }, + { + profileData.postValue(Error()) + }) + .addTo(disposeables) + + } + } + + fun newAvatar(uri: Uri, context: Context) { + val cacheFile = getCacheFileForName(context, AVATAR_FILE_NAME) + + resizeImage(uri, context, AVATAR_SIZE, AVATAR_SIZE, cacheFile, avatarData) + } + + fun newHeader(uri: Uri, context: Context) { + val cacheFile = getCacheFileForName(context, HEADER_FILE_NAME) + + resizeImage(uri, context, HEADER_WIDTH, HEADER_HEIGHT, cacheFile, headerData) + } + + private fun resizeImage(uri: Uri, + context: Context, + resizeWidth: Int, + resizeHeight: Int, + cacheFile: File, + imageLiveData: MutableLiveData>) { + + Single.fromCallable { + val contentResolver = context.contentResolver + val sourceBitmap = getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight) + + if (sourceBitmap == null) { + throw Exception() + } + + //dont upscale image if its smaller than the desired size + val bitmap = + if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) { + sourceBitmap + } else { + Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true) + } + + if (!saveBitmapToFile(bitmap, cacheFile)) { + throw Exception() + } + + bitmap + }.subscribeOn(Schedulers.io()) + .subscribe({ + imageLiveData.postValue(Success(it)) + }, { + imageLiveData.postValue(Error()) + }) + .addTo(disposeables) + } + + fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List, context: Context) { + + if(saveData.value is Loading || profileData.value !is Success) { + return + } + + val displayName = if (oldProfileData?.displayName == newDisplayName) { + null + } else { + newDisplayName.toRequestBody(MultipartBody.FORM) + } + + val note = if (oldProfileData?.source?.note == newNote) { + null + } else { + newNote.toRequestBody(MultipartBody.FORM) + } + + val locked = if (oldProfileData?.locked == newLocked) { + null + } else { + newLocked.toString().toRequestBody(MultipartBody.FORM) + } + + val avatar = if (avatarData.value is Success && avatarData.value?.data != null) { + val avatarBody = getCacheFileForName(context, AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody) + } else { + null + } + + val header = if (headerData.value is Success && headerData.value?.data != null) { + val headerBody = getCacheFileForName(context, HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody) + } else { + null + } + + // when one field changed, all have to be sent or they unchanged ones would get overridden + val fieldsUnchanged = oldProfileData?.source?.fields == newFields + val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged) + val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged) + val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) + val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) + + if (displayName == null && note == null && locked == null && avatar == null && header == null + && field1 == null && field2 == null && field3 == null && field4 == null) { + /** if nothing has changed, there is no need to make a network request */ + saveData.postValue(Success()) + return + } + + mastodonApi.accountUpdateCredentials(displayName, note, locked, avatar, header, + field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + ).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + val newProfileData = response.body() + if (!response.isSuccessful || newProfileData == null) { + val errorResponse = response.errorBody()?.string() + val errorMsg = if(!errorResponse.isNullOrBlank()) { + try { + JSONObject(errorResponse).optString("error", null) + } catch (e: JSONException) { + null + } + } else { + null + } + saveData.postValue(Error(errorMessage = errorMsg)) + return + } + saveData.postValue(Success()) + eventHub.dispatch(ProfileEditedEvent(newProfileData)) + } + + override fun onFailure(call: Call, t: Throwable) { + saveData.postValue(Error()) + } + }) + + } + + // cache activity state for rotation change + fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { + if(profileData.value is Success) { + val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) + val newProfile = profileData.value?.data?.copy(displayName = newDisplayName, + locked = newLocked, source = newProfileSource) + + profileData.postValue(Success(newProfile)) + } + + } + + + private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { + if(fieldsUnchanged || newField == null) { + return null + } + return Pair( + newField.name.toRequestBody(MultipartBody.FORM), + newField.value.toRequestBody(MultipartBody.FORM) + ) + } + + private fun getCacheFileForName(context: Context, filename: String): File { + return File(context.cacheDir, filename) + } + + private fun saveBitmapToFile(bitmap: Bitmap, file: File): Boolean { + + val outputStream: OutputStream + + try { + outputStream = FileOutputStream(file) + } catch (e: FileNotFoundException) { + Log.w(TAG, Log.getStackTraceString(e)) + return false + } + + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + IOUtils.closeQuietly(outputStream) + + return true + } + + override fun onCleared() { + disposeables.dispose() + } + + fun obtainInstance() { + if(instanceData.value == null || instanceData.value is Error) { + instanceData.postValue(Loading()) + + mastodonApi.getInstance().subscribe( + { instance -> + instanceData.postValue(Success(instance)) + }, + { + instanceData.postValue(Error()) + }) + .addTo(disposeables) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt new file mode 100644 index 0000000..22f509b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -0,0 +1,111 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.viewmodel + +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.replacedFirstWhich +import com.keylesspalace.tusky.util.withoutFirstWhich +import io.reactivex.Observable +import io.reactivex.subjects.BehaviorSubject +import io.reactivex.subjects.PublishSubject +import java.io.IOException +import java.net.ConnectException +import javax.inject.Inject + + +internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { + enum class LoadingState { + INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER + } + + enum class Event { + CREATE_ERROR, DELETE_ERROR, RENAME_ERROR + } + + data class State(val lists: List, val loadingState: LoadingState) + + val state: Observable get() = _state + val events: Observable get() = _events + private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL)) + private val _events = PublishSubject.create() + + fun retryLoading() { + loadIfNeeded() + } + + private fun loadIfNeeded() { + val state = _state.value!! + if (state.loadingState == LoadingState.LOADING || state.lists.isNotEmpty()) return + updateState { + copy(loadingState = LoadingState.LOADING) + } + + api.getLists().subscribe({ lists -> + updateState { + copy( + lists = lists, + loadingState = LoadingState.LOADED + ) + } + }, { err -> + updateState { + copy(loadingState = if (err is IOException || err is ConnectException) + LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER) + } + }).autoDispose() + } + + fun createNewList(listName: String) { + api.createList(listName).subscribe({ list -> + updateState { + copy(lists = lists + list) + } + }, { + sendEvent(Event.CREATE_ERROR) + }).autoDispose() + } + + fun renameList(listId: String, listName: String) { + api.updateList(listId, listName).subscribe({ list -> + updateState { + copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + } + }, { + sendEvent(Event.RENAME_ERROR) + }).autoDispose() + } + + fun deleteList(listId: String) { + api.deleteList(listId).subscribe({ + updateState { + copy(lists = lists.withoutFirstWhich { it.id == listId }) + } + }, { + sendEvent(Event.DELETE_ERROR) + }).autoDispose() + } + + private inline fun updateState(crossinline fn: State.() -> State) { + _state.onNext(fn(_state.value!!)) + } + + private fun sendEvent(event: Event) { + _events.onNext(event) + } +} diff --git a/app/src/main/res/anim/explode.xml b/app/src/main/res/anim/explode.xml new file mode 100644 index 0000000..08001ae --- /dev/null +++ b/app/src/main/res/anim/explode.xml @@ -0,0 +1,12 @@ + + + + + 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 0000000..972e757 --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file 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 0000000..9b48ae8 --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_left.xml b/app/src/main/res/anim/slide_from_left.xml new file mode 100644 index 0000000..5c7fe52 --- /dev/null +++ b/app/src/main/res/anim/slide_from_left.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_right.xml b/app/src/main/res/anim/slide_from_right.xml new file mode 100644 index 0000000..3c595d0 --- /dev/null +++ b/app/src/main/res/anim/slide_from_right.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_left.xml b/app/src/main/res/anim/slide_to_left.xml new file mode 100644 index 0000000..21688e2 --- /dev/null +++ b/app/src/main/res/anim/slide_to_left.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_right.xml b/app/src/main/res/anim/slide_to_right.xml new file mode 100644 index 0000000..8ded764 --- /dev/null +++ b/app/src/main/res/anim/slide_to_right.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/account_tab_font_color.xml b/app/src/main/res/color/account_tab_font_color.xml new file mode 100644 index 0000000..c81c01a --- /dev/null +++ b/app/src/main/res/color/account_tab_font_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/color_background_transparent_60.xml b/app/src/main/res/color/color_background_transparent_60.xml new file mode 100644 index 0000000..0a09f2a --- /dev/null +++ b/app/src/main/res/color/color_background_transparent_60.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/compound_button_color.xml b/app/src/main/res/color/compound_button_color.xml new file mode 100644 index 0000000..8b151c7 --- /dev/null +++ b/app/src/main/res/color/compound_button_color.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/emoji_reaction_button.xml b/app/src/main/res/color/emoji_reaction_button.xml new file mode 100644 index 0000000..7d1c700 --- /dev/null +++ b/app/src/main/res/color/emoji_reaction_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/text_input_layout_box_stroke_color.xml b/app/src/main/res/color/text_input_layout_box_stroke_color.xml new file mode 100644 index 0000000..5b21f78 --- /dev/null +++ b/app/src/main/res/color/text_input_layout_box_stroke_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/elephant_error.png b/app/src/main/res/drawable-hdpi/elephant_error.png new file mode 100644 index 0000000..71310c5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/elephant_error.png differ diff --git a/app/src/main/res/drawable-hdpi/elephant_friend.png b/app/src/main/res/drawable-hdpi/elephant_friend.png new file mode 100644 index 0000000..1e3dcfe Binary files /dev/null and b/app/src/main/res/drawable-hdpi/elephant_friend.png differ diff --git a/app/src/main/res/drawable-hdpi/elephant_friend_empty.png b/app/src/main/res/drawable-hdpi/elephant_friend_empty.png new file mode 100644 index 0000000..e432b43 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/elephant_friend_empty.png differ diff --git a/app/src/main/res/drawable-hdpi/elephant_offline.png b/app/src/main/res/drawable-hdpi/elephant_offline.png new file mode 100644 index 0000000..b8141fb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/elephant_offline.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_notify.png b/app/src/main/res/drawable-hdpi/ic_notify.png new file mode 100644 index 0000000..154b492 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/splash.png b/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..e1faaa3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/app/src/main/res/drawable-mdpi/elephant_error.png b/app/src/main/res/drawable-mdpi/elephant_error.png new file mode 100644 index 0000000..a81667a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/elephant_error.png differ diff --git a/app/src/main/res/drawable-mdpi/elephant_friend.png b/app/src/main/res/drawable-mdpi/elephant_friend.png new file mode 100644 index 0000000..798a4f5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/elephant_friend.png differ diff --git a/app/src/main/res/drawable-mdpi/elephant_friend_empty.png b/app/src/main/res/drawable-mdpi/elephant_friend_empty.png new file mode 100644 index 0000000..4e19d91 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/elephant_friend_empty.png differ diff --git a/app/src/main/res/drawable-mdpi/elephant_offline.png b/app/src/main/res/drawable-mdpi/elephant_offline.png new file mode 100644 index 0000000..0a574c9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/elephant_offline.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notify.png b/app/src/main/res/drawable-mdpi/ic_notify.png new file mode 100644 index 0000000..bd57740 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/splash.png b/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..d8451f3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/app/src/main/res/drawable-v24/ic_notoemoji.xml b/app/src/main/res/drawable-v24/ic_notoemoji.xml new file mode 100644 index 0000000..d016e35 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_notoemoji.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/app/src/main/res/drawable-v26/ic_launcher_foreground.xml new file mode 100644 index 0000000..677bf6b --- /dev/null +++ b/app/src/main/res/drawable-v26/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-v26/launcher_shadow_gradient.xml b/app/src/main/res/drawable-v26/launcher_shadow_gradient.xml new file mode 100644 index 0000000..98ce833 --- /dev/null +++ b/app/src/main/res/drawable-v26/launcher_shadow_gradient.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/elephant_error.png b/app/src/main/res/drawable-xhdpi/elephant_error.png new file mode 100644 index 0000000..e21b51e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/elephant_error.png differ diff --git a/app/src/main/res/drawable-xhdpi/elephant_friend.png b/app/src/main/res/drawable-xhdpi/elephant_friend.png new file mode 100644 index 0000000..1273059 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/elephant_friend.png differ diff --git a/app/src/main/res/drawable-xhdpi/elephant_friend_empty.png b/app/src/main/res/drawable-xhdpi/elephant_friend_empty.png new file mode 100644 index 0000000..ebed037 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/elephant_friend_empty.png differ diff --git a/app/src/main/res/drawable-xhdpi/elephant_offline.png b/app/src/main/res/drawable-xhdpi/elephant_offline.png new file mode 100644 index 0000000..3977552 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/elephant_offline.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notify.png b/app/src/main/res/drawable-xhdpi/ic_notify.png new file mode 100644 index 0000000..7d676ba Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/splash.png b/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..2ea0f9f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xxhdpi/elephant_error.png b/app/src/main/res/drawable-xxhdpi/elephant_error.png new file mode 100644 index 0000000..f662783 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/elephant_error.png differ diff --git a/app/src/main/res/drawable-xxhdpi/elephant_friend.png b/app/src/main/res/drawable-xxhdpi/elephant_friend.png new file mode 100644 index 0000000..7d6309d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/elephant_friend.png differ diff --git a/app/src/main/res/drawable-xxhdpi/elephant_friend_empty.png b/app/src/main/res/drawable-xxhdpi/elephant_friend_empty.png new file mode 100644 index 0000000..7efad54 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/elephant_friend_empty.png differ diff --git a/app/src/main/res/drawable-xxhdpi/elephant_offline.png b/app/src/main/res/drawable-xxhdpi/elephant_offline.png new file mode 100644 index 0000000..aced82d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/elephant_offline.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notify.png b/app/src/main/res/drawable-xxhdpi/ic_notify.png new file mode 100644 index 0000000..6a62def Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/splash.png b/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..9bd2ec3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/elephant_error.png b/app/src/main/res/drawable-xxxhdpi/elephant_error.png new file mode 100644 index 0000000..12bc80e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/elephant_error.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/elephant_friend.png b/app/src/main/res/drawable-xxxhdpi/elephant_friend.png new file mode 100644 index 0000000..6117b5f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/elephant_friend.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/elephant_friend_empty.png b/app/src/main/res/drawable-xxxhdpi/elephant_friend_empty.png new file mode 100644 index 0000000..6da3375 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/elephant_friend_empty.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/elephant_offline.png b/app/src/main/res/drawable-xxxhdpi/elephant_offline.png new file mode 100644 index 0000000..cbf8083 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/elephant_offline.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notify.png b/app/src/main/res/drawable-xxxhdpi/ic_notify.png new file mode 100644 index 0000000..d2a507d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notify.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/splash.png b/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..2ea358a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/app/src/main/res/drawable/avatar_border.xml b/app/src/main/res/drawable/avatar_border.xml new file mode 100644 index 0000000..aca56e1 --- /dev/null +++ b/app/src/main/res/drawable/avatar_border.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/avatar_default.xml b/app/src/main/res/drawable/avatar_default.xml new file mode 100644 index 0000000..d2c9e4c --- /dev/null +++ b/app/src/main/res/drawable/avatar_default.xml @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/background_dialog_activity.xml b/app/src/main/res/drawable/background_dialog_activity.xml new file mode 100644 index 0000000..80cff38 --- /dev/null +++ b/app/src/main/res/drawable/background_dialog_activity.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_splash.xml b/app/src/main/res/drawable/background_splash.xml new file mode 100644 index 0000000..d79dee5 --- /dev/null +++ b/app/src/main/res/drawable/background_splash.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/card_frame.xml b/app/src/main/res/drawable/card_frame.xml new file mode 100644 index 0000000..525731b --- /dev/null +++ b/app/src/main/res/drawable/card_frame.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/card_image_placeholder.xml b/app/src/main/res/drawable/card_image_placeholder.xml new file mode 100644 index 0000000..1ca515a --- /dev/null +++ b/app/src/main/res/drawable/card_image_placeholder.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_thread_line.xml b/app/src/main/res/drawable/conversation_thread_line.xml new file mode 100644 index 0000000..5a87f79 --- /dev/null +++ b/app/src/main/res/drawable/conversation_thread_line.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/description_bg_expanded.xml b/app/src/main/res/drawable/description_bg_expanded.xml new file mode 100644 index 0000000..db2eebd --- /dev/null +++ b/app/src/main/res/drawable/description_bg_expanded.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_access_time.xml b/app/src/main/res/drawable/ic_access_time.xml new file mode 100644 index 0000000..2239a4f --- /dev/null +++ b/app/src/main/res/drawable/ic_access_time.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_account_settings.xml b/app/src/main/res/drawable/ic_account_settings.xml new file mode 100644 index 0000000..d13907d --- /dev/null +++ b/app/src/main/res/drawable/ic_account_settings.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_a_photo_32dp.xml b/app/src/main/res/drawable/ic_add_a_photo_32dp.xml new file mode 100644 index 0000000..172c5ac --- /dev/null +++ b/app/src/main/res/drawable/ic_add_a_photo_32dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_alert_circle.xml b/app/src/main/res/drawable/ic_alert_circle.xml new file mode 100644 index 0000000..4c894f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert_circle.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_attach_file_24dp.xml b/app/src/main/res/drawable/ic_attach_file_24dp.xml new file mode 100644 index 0000000..806cac0 --- /dev/null +++ b/app/src/main/res/drawable/ic_attach_file_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bbcode_24dp.xml b/app/src/main/res/drawable/ic_bbcode_24dp.xml new file mode 100644 index 0000000..d068c02 --- /dev/null +++ b/app/src/main/res/drawable/ic_bbcode_24dp.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_blobmoji.xml b/app/src/main/res/drawable/ic_blobmoji.xml new file mode 100644 index 0000000..be3332c --- /dev/null +++ b/app/src/main/res/drawable/ic_blobmoji.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_bookmark_24dp.xml b/app/src/main/res/drawable/ic_bookmark_24dp.xml new file mode 100644 index 0000000..803bca9 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark_active_24dp.xml b/app/src/main/res/drawable/ic_bookmark_active_24dp.xml new file mode 100644 index 0000000..217b78b --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_active_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bot_24dp.xml b/app/src/main/res/drawable/ic_bot_24dp.xml new file mode 100644 index 0000000..26d4c9e --- /dev/null +++ b/app/src/main/res/drawable/ic_bot_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_briefcase.xml b/app/src/main/res/drawable/ic_briefcase.xml new file mode 100644 index 0000000..eeb8061 --- /dev/null +++ b/app/src/main/res/drawable/ic_briefcase.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bullhorn_24dp.xml b/app/src/main/res/drawable/ic_bullhorn_24dp.xml new file mode 100644 index 0000000..e290b24 --- /dev/null +++ b/app/src/main/res/drawable/ic_bullhorn_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cancel_24dp.xml b/app/src/main/res/drawable/ic_cancel_24dp.xml new file mode 100644 index 0000000..7d2b57e --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_24dp.xml b/app/src/main/res/drawable/ic_check_24dp.xml new file mode 100644 index 0000000..6541ee3 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_32dp.xml b/app/src/main/res/drawable/ic_check_32dp.xml new file mode 100644 index 0000000..9325c89 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_32dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml b/app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml new file mode 100644 index 0000000..cb610e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..7ff119e --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_clear_24dp.xml b/app/src/main/res/drawable/ic_clear_24dp.xml new file mode 100644 index 0000000..0a244b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_24dp.xml b/app/src/main/res/drawable/ic_close_24dp.xml new file mode 100644 index 0000000..081e405 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_create_24dp.xml b/app/src/main/res/drawable/ic_create_24dp.xml new file mode 100644 index 0000000..d74fe13 --- /dev/null +++ b/app/src/main/res/drawable/ic_create_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cw_24dp.xml b/app/src/main/res/drawable/ic_cw_24dp.xml new file mode 100644 index 0000000..62713d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_cw_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_indicator_24dp.xml b/app/src/main/res/drawable/ic_drag_indicator_24dp.xml new file mode 100644 index 0000000..ab9d5f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_indicator_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_indicator_horiz_24dp.xml b/app/src/main/res/drawable/ic_drag_indicator_horiz_24dp.xml new file mode 100644 index 0000000..eb61122 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_indicator_horiz_24dp.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_email_24dp.xml b/app/src/main/res/drawable/ic_email_24dp.xml new file mode 100644 index 0000000..1bcee1b --- /dev/null +++ b/app/src/main/res/drawable/ic_email_24dp.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_emoji_24dp.xml b/app/src/main/res/drawable/ic_emoji_24dp.xml new file mode 100644 index 0000000..5a73c89 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_emoji_34dp.xml b/app/src/main/res/drawable/ic_emoji_34dp.xml new file mode 100644 index 0000000..b00cb96 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_34dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exit_to_app_24px.xml b/app/src/main/res/drawable/ic_exit_to_app_24px.xml new file mode 100644 index 0000000..ce5bd59 --- /dev/null +++ b/app/src/main/res/drawable/ic_exit_to_app_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_24dp.xml b/app/src/main/res/drawable/ic_eye_24dp.xml new file mode 100644 index 0000000..83a3463 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favourite_24dp.xml b/app/src/main/res/drawable/ic_favourite_24dp.xml new file mode 100644 index 0000000..5826bf5 --- /dev/null +++ b/app/src/main/res/drawable/ic_favourite_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favourite_active_24dp.xml b/app/src/main/res/drawable/ic_favourite_active_24dp.xml new file mode 100644 index 0000000..2eb3014 --- /dev/null +++ b/app/src/main/res/drawable/ic_favourite_active_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download_black_24dp.xml b/app/src/main/res/drawable/ic_file_download_black_24dp.xml new file mode 100644 index 0000000..f5f7221 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_forum_24px.xml b/app/src/main/res/drawable/ic_forum_24px.xml new file mode 100644 index 0000000..b9d066d --- /dev/null +++ b/app/src/main/res/drawable/ic_forum_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_hashtag.xml b/app/src/main/res/drawable/ic_hashtag.xml new file mode 100644 index 0000000..c7a3bc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_hashtag.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hide_media_24dp.xml b/app/src/main/res/drawable/ic_hide_media_24dp.xml new file mode 100644 index 0000000..106a53d --- /dev/null +++ b/app/src/main/res/drawable/ic_hide_media_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_home_24dp.xml b/app/src/main/res/drawable/ic_home_24dp.xml new file mode 100644 index 0000000..4c6bc0e --- /dev/null +++ b/app/src/main/res/drawable/ic_home_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_html_24dp.xml b/app/src/main/res/drawable/ic_html_24dp.xml new file mode 100644 index 0000000..3896956 --- /dev/null +++ b/app/src/main/res/drawable/ic_html_24dp.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml new file mode 100644 index 0000000..4c2fb88 --- /dev/null +++ b/app/src/main/res/drawable/ic_list.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_local_24dp.xml b/app/src/main/res/drawable/ic_local_24dp.xml new file mode 100644 index 0000000..2953007 --- /dev/null +++ b/app/src/main/res/drawable/ic_local_24dp.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock_open_24dp.xml b/app/src/main/res/drawable/ic_lock_open_24dp.xml new file mode 100644 index 0000000..1e9d0db --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_open_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock_outline_24dp.xml b/app/src/main/res/drawable/ic_lock_outline_24dp.xml new file mode 100644 index 0000000..a8e4201 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..717009a --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_markdown.xml b/app/src/main/res/drawable/ic_markdown.xml new file mode 100644 index 0000000..dd6aab4 --- /dev/null +++ b/app/src/main/res/drawable/ic_markdown.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_menu_share_24dp.xml b/app/src/main/res/drawable/ic_menu_share_24dp.xml new file mode 100644 index 0000000..dd1be97 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_share_24dp.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_more_horiz_24dp.xml b/app/src/main/res/drawable/ic_more_horiz_24dp.xml new file mode 100644 index 0000000..c774133 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_horiz_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_box_24dp.xml b/app/src/main/res/drawable/ic_music_box_24dp.xml new file mode 100644 index 0000000..c0243d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_box_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_music_box_preview_24dp.xml b/app/src/main/res/drawable/ic_music_box_preview_24dp.xml new file mode 100644 index 0000000..6790179 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_box_preview_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mute_24dp.xml b/app/src/main/res/drawable/ic_mute_24dp.xml new file mode 100644 index 0000000..bcdbb5a --- /dev/null +++ b/app/src/main/res/drawable/ic_mute_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notebook.xml b/app/src/main/res/drawable/ic_notebook.xml new file mode 100644 index 0000000..93ff789 --- /dev/null +++ b/app/src/main/res/drawable/ic_notebook.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notifications_24dp.xml b/app/src/main/res/drawable/ic_notifications_24dp.xml new file mode 100644 index 0000000..d2f7aac --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notifications_active_24dp.xml b/app/src/main/res/drawable/ic_notifications_active_24dp.xml new file mode 100644 index 0000000..9a60daa --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_active_24dp.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notifications_off_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_24dp.xml new file mode 100644 index 0000000..627eafd --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_off_24dp.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notoemoji.xml b/app/src/main/res/drawable/ic_notoemoji.xml new file mode 100644 index 0000000..55628c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_notoemoji.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_person_add_24dp.xml b/app/src/main/res/drawable/ic_person_add_24dp.xml new file mode 100644 index 0000000..dc849f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_add_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_photo_24dp.xml b/app/src/main/res/drawable/ic_photo_24dp.xml new file mode 100644 index 0000000..d0ebff0 --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_indicator.xml b/app/src/main/res/drawable/ic_play_indicator.xml new file mode 100644 index 0000000..4cdae50 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_indicator.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_plus_24dp.xml b/app/src/main/res/drawable/ic_plus_24dp.xml new file mode 100644 index 0000000..2ba0da8 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_poll_24dp.xml b/app/src/main/res/drawable/ic_poll_24dp.xml new file mode 100644 index 0000000..5e55dc5 --- /dev/null +++ b/app/src/main/res/drawable/ic_poll_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_preview_24dp.xml b/app/src/main/res/drawable/ic_preview_24dp.xml new file mode 100644 index 0000000..10523f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_preview_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_public_24dp.xml b/app/src/main/res/drawable/ic_public_24dp.xml new file mode 100644 index 0000000..6ef182e --- /dev/null +++ b/app/src/main/res/drawable/ic_public_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml b/app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml new file mode 100644 index 0000000..160b237 --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reblog_18dp.xml b/app/src/main/res/drawable/ic_reblog_18dp.xml new file mode 100644 index 0000000..029e711 --- /dev/null +++ b/app/src/main/res/drawable/ic_reblog_18dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reblog_24dp.xml b/app/src/main/res/drawable/ic_reblog_24dp.xml new file mode 100644 index 0000000..0fe908e --- /dev/null +++ b/app/src/main/res/drawable/ic_reblog_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reblog_active_24dp.xml b/app/src/main/res/drawable/ic_reblog_active_24dp.xml new file mode 100644 index 0000000..8d28a40 --- /dev/null +++ b/app/src/main/res/drawable/ic_reblog_active_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reblog_direct_24dp.xml b/app/src/main/res/drawable/ic_reblog_direct_24dp.xml new file mode 100644 index 0000000..0f53287 --- /dev/null +++ b/app/src/main/res/drawable/ic_reblog_direct_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_reblog_private_24dp.xml b/app/src/main/res/drawable/ic_reblog_private_24dp.xml new file mode 100644 index 0000000..078eaf7 --- /dev/null +++ b/app/src/main/res/drawable/ic_reblog_private_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml b/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml new file mode 100644 index 0000000..43169d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reject_24dp.xml b/app/src/main/res/drawable/ic_reject_24dp.xml new file mode 100644 index 0000000..d11cc5c --- /dev/null +++ b/app/src/main/res/drawable/ic_reject_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_repeat_24dp.xml b/app/src/main/res/drawable/ic_repeat_24dp.xml new file mode 100644 index 0000000..aaa76ae --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply_18dp.xml b/app/src/main/res/drawable/ic_reply_18dp.xml new file mode 100644 index 0000000..234bc07 --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_18dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply_24dp.xml b/app/src/main/res/drawable/ic_reply_24dp.xml new file mode 100644 index 0000000..6085ff0 --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply_all_24dp.xml b/app/src/main/res/drawable/ic_reply_all_24dp.xml new file mode 100644 index 0000000..9da31f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_all_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_send_24dp.xml b/app/src/main/res/drawable/ic_send_24dp.xml new file mode 100644 index 0000000..8916aa9 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..b852054 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_star_24dp.xml b/app/src/main/res/drawable/ic_star_24dp.xml new file mode 100644 index 0000000..8689142 --- /dev/null +++ b/app/src/main/res/drawable/ic_star_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sticker.xml b/app/src/main/res/drawable/ic_sticker.xml new file mode 100644 index 0000000..671937c --- /dev/null +++ b/app/src/main/res/drawable/ic_sticker.xml @@ -0,0 +1,16 @@ + + + diff --git a/app/src/main/res/drawable/ic_tabs.xml b/app/src/main/res/drawable/ic_tabs.xml new file mode 100644 index 0000000..3de93e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_tabs.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_tusky.xml b/app/src/main/res/drawable/ic_tusky.xml new file mode 100644 index 0000000..0dc845c --- /dev/null +++ b/app/src/main/res/drawable/ic_tusky.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_twemoji.xml b/app/src/main/res/drawable/ic_twemoji.xml new file mode 100644 index 0000000..70c4b51 --- /dev/null +++ b/app/src/main/res/drawable/ic_twemoji.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_unmute_24dp.xml b/app/src/main/res/drawable/ic_unmute_24dp.xml new file mode 100644 index 0000000..fe37f7f --- /dev/null +++ b/app/src/main/res/drawable/ic_unmute_24dp.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_videocam_24dp.xml b/app/src/main/res/drawable/ic_videocam_24dp.xml new file mode 100644 index 0000000..1614d02 --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/materialdrawer_shape_large.xml b/app/src/main/res/drawable/materialdrawer_shape_large.xml new file mode 100644 index 0000000..ba626b6 --- /dev/null +++ b/app/src/main/res/drawable/materialdrawer_shape_large.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/materialdrawer_shape_small.xml b/app/src/main/res/drawable/materialdrawer_shape_small.xml new file mode 100644 index 0000000..7bdd429 --- /dev/null +++ b/app/src/main/res/drawable/materialdrawer_shape_small.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/md_bold.xml b/app/src/main/res/drawable/md_bold.xml new file mode 100644 index 0000000..99b958c --- /dev/null +++ b/app/src/main/res/drawable/md_bold.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/md_code.xml b/app/src/main/res/drawable/md_code.xml new file mode 100644 index 0000000..aeac52a --- /dev/null +++ b/app/src/main/res/drawable/md_code.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/md_italic.xml b/app/src/main/res/drawable/md_italic.xml new file mode 100644 index 0000000..274ead6 --- /dev/null +++ b/app/src/main/res/drawable/md_italic.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/md_link.xml b/app/src/main/res/drawable/md_link.xml new file mode 100644 index 0000000..1220397 --- /dev/null +++ b/app/src/main/res/drawable/md_link.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/md_strikethrough.xml b/app/src/main/res/drawable/md_strikethrough.xml new file mode 100644 index 0000000..1002955 --- /dev/null +++ b/app/src/main/res/drawable/md_strikethrough.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/media_preview_outline.xml b/app/src/main/res/drawable/media_preview_outline.xml new file mode 100644 index 0000000..a15ba5c --- /dev/null +++ b/app/src/main/res/drawable/media_preview_outline.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_warning_bg.xml b/app/src/main/res/drawable/media_warning_bg.xml new file mode 100644 index 0000000..93ff4e0 --- /dev/null +++ b/app/src/main/res/drawable/media_warning_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_background.xml b/app/src/main/res/drawable/message_background.xml new file mode 100644 index 0000000..36c6606 --- /dev/null +++ b/app/src/main/res/drawable/message_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/poll_option_background.xml b/app/src/main/res/drawable/poll_option_background.xml new file mode 100644 index 0000000..90aa51d --- /dev/null +++ b/app/src/main/res/drawable/poll_option_background.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/poll_option_shape.xml b/app/src/main/res/drawable/poll_option_shape.xml new file mode 100644 index 0000000..da097f3 --- /dev/null +++ b/app/src/main/res/drawable/poll_option_shape.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_badge_background.xml b/app/src/main/res/drawable/profile_badge_background.xml new file mode 100644 index 0000000..be4bcf3 --- /dev/null +++ b/app/src/main/res/drawable/profile_badge_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/report_success_background.xml b/app/src/main/res/drawable/report_success_background.xml new file mode 100644 index 0000000..147e048 --- /dev/null +++ b/app/src/main/res/drawable/report_success_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/round_button.xml b/app/src/main/res/drawable/round_button.xml new file mode 100644 index 0000000..a6c0da1 --- /dev/null +++ b/app/src/main/res/drawable/round_button.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/spellcheck.xml b/app/src/main/res/drawable/spellcheck.xml new file mode 100644 index 0000000..79f2251 --- /dev/null +++ b/app/src/main/res/drawable/spellcheck.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/status_divider.xml b/app/src/main/res/drawable/status_divider.xml new file mode 100644 index 0000000..37fbbab --- /dev/null +++ b/app/src/main/res/drawable/status_divider.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/unread_shape.xml b/app/src/main/res/drawable/unread_shape.xml new file mode 100644 index 0000000..b85c7f4 --- /dev/null +++ b/app/src/main/res/drawable/unread_shape.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/fragment_report_done.xml b/app/src/main/res/layout-land/fragment_report_done.xml new file mode 100644 index 0000000..d9900a2 --- /dev/null +++ b/app/src/main/res/layout-land/fragment_report_done.xml @@ -0,0 +1,109 @@ + + + + + + + + + +