diff --git a/CHANGELOG b/CHANGELOG new file mode 100755 index 0000000..615f49e --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,195 @@ +v15.9 to v16.5: https://gitlab.com/AwaisKing/instagrabber/-/releases +From v16.6: https://github.com/austinhuang0131/barinsta/releases + +v15.9 +note: there will be no F-Droid updates from this version (v15.9) and onward, download updates from repo's Releases page. + ++ added user stories in Feed ++ added frame to currently showing slider item ++ removed tap to pause/resume from Post viewer, replaced with controller ++ fixed swipe not working when posts are opened from stories ++ fixed comments not showing for slider items + +v15.8 ++ added user's website in profile ++ fixed caption mentions length (@kernoeb) ++ fixed some translations (@kernoeb) ++ fixd feed captions merging with "... more" + +v15.4 ++ ADDED FRENCH AND SPANISH!!! ++-> Huge thanks to @kernoeb (Telegram) for French translation and @sguinetti (GitLab) for Spanish translation!! + ++ added custom post time format support! ++ fixed flickering after changing settings ++ fixed posts not showing after searching from a private profile ++ fixed stories and profile pictures not downloading in user folders even when option was enabled ++ fixed issues with feed, discover and post viewer ++ fixed search suggestions crashes + +v15.2 ++ fixed feed video not pausing when opened in post viewer ++ added 1 new profile picture view mode ++ added fields in LogCollector to better understand how things went wrong ++ better comments, story, feed, discover and suggestion fetchers + +v15.0 ++ added support for Instagram.com urls! (: ++ added "Downloaded" check on posts if they've already been downloaded (as per suggestion) ++ fixed highlights scrolling issues ++ fixed comments issues ++ fixed posts weren't showing after searching anything from a private account ++ fixed suggestions not showing sometimes ++ fixed Stories viewer swipe issues ++ fixed Import/Export dialog not showing on old phones ++ added user's name in suggestions search list ++ added comment likes in Comments viewer + ++ sending logs won't add empty logs to archive anymore! ++ fixed no new line in logs ++ handled Login WebView lifecycles ++ a better way of handling highlight swipes ++ removed useless code parts + +v14.5 ++ added changelog after update ++ added swipe support in both Discover/Explore and Feed pages ++ added Send Logs button in Settings to send logs when something doesn't work ++ added clickable user profile in Post Viewer ++ fixed weirdly collapsing toolbar when toolbar is shown at the top + +v14.0 ++ added theme selection support ++ added import/export settings, favorites and logins functionality (thanks to Airikr [@edgren] on Telegram) ++ added support for downloading slider items from User Feed page ++ added support for usernames and hashtags in user's biography/about text ++ added multiple selection in Discover/Explore page ++ added post date for feed items ++ added some more date formats ++ copyable feed item caption (long tap) ++ fixed late refresh indicator in Followers/Following comparison mode ++ changed feed item size to squares ++ fixed some caption text issues having mentions and hashtags ++ removed clipboard listener (stopped working after some changes) ++ added log collector for different crash scenarios + +v13.7 ++ fixed custom download folder selection issues + +v13.3 ++ added discover/explore page (only for logged in users) ++ added function to remove IPTC tracking data from downloaded pictures (thanks to Airikr [@edgren] on Telegram) ++ added multiple accounts support (quick access) and favorites (a suggestion from Saurabh on Telegram) ++ added custom download folder option, you can select where to download posts (a suggestion from Airikr) ++ added desktop mode toggle in Login activity (a suggestion from Eymen on Telegram) ++ added post date in post viewer (a suggestion from W on Telegram) ++ added post time format settings [ Settings > Post Time Settings ] ++ fixed some icons and layouts ++ removed color from slider items in feed ++ removed useless methods and properties ++ better way of handling multiple stories and posts (sliders) in post viewer ++ tried to make notifications grouped together.. hope they work ++ some other fixes and additions which i probably forgot, cause i'm a human ffs + +v13.0 ++ fixed crash when searching hashtags ++ added lazy loading for hashtags ++ added Show Feed option in Settings (feed may crash app on some phones) ++ added null check in Download async ++ better/adapatable icon ++ fixed sheet dialog themes + +v12.7 ++ (probably) fixed inflating issue in some devices + +v12.5 ++ some small performance improvements + +v12.0 ++ added feed!! (only for logged in users) ++ fixed multiple hashtags with no spaces between them ++ stopped activity from recreating when nothing changed ++ changed highlights to RecyclerView instead of HorizontalScrollView ++ changed some numbers and precisions cause she left me on read + +v11.0 ++ added crash reporting library ++ added mute/unmute for session ++ better profile picture viewer + profile picture info ++ better swipe gesture ++ fixed mention and hashtag issues + +v10.0 +NOTE: YOU MAY NEED TO LOGIN TO VIEW PROFILES CAUSE OF INSTAGRAM CHANGES. ++ added direct download multiple posts dialog ++ fixed notification problems ++ fixed some direct download problems ++ fixed batch download and username folder not creating in some activities ++ fixed update checker + +v9.0 ++ added search in comments viewer ++ added settings to auto or lazy load posts ++ added user & hashtag stack when back pressed ++ added loading icon when loading posts ++ profile info bar sticks to top ++ fixed highlights and profile picture size in portrait and landscape mode ++ fixed posts loading from other user when restarting app ++ fixed posts size changing when scrolled ++ users & hashtag search shows only users or hashtag when first char is @ or # ++ scrolls to top when back pressed + +v8.0 ++ added pull-to-refresh layout in main posts, followers/following viewer ++ added search in followers/following viewer ++ added video views in post viewer ++ added animation when showing highlights (if available) ++ fixed long usernames not animating (marquee) ++ fixed padding and size in portrait and landscape modes ++ fixed accounts showing personal followers/following when account has 0 followers/following ++ fixed double ripple when tapped on profile picture ++ smaller story border around profile picture + +v7.0 ++ added comments viewer!! ++ added highest quality post fetcher ++ added loading indicator where it was missing before ++ fixed highlight name alignment ++ fixed swiping on posts opened via Share or link + +v6.0 ++ added story highlights!! (issue #5) ++ added button to view posts posted in stories ++ added verified badge for accounts ++ fixed posts & story swiping issues ++ fixed slow loading and stuff ++ fixed different margins and sizes ++ fixed activity not recreating after settings dialogs closed + +v5.0 ++ added followers / following checker ++ added search view suggestions for usernames & hashtags ++ added sliding profile container ++ fixed batch download permission issue (issue #4) ++ fixed some small screen panning issues ++ fixed search view width ++ fixed update checker + +v4.0 ++ fixed Login and Visit project page button codes. + +v3.0 ++ fixed posts merged from different accounts when searched while posts are loading! ++ view stories (only if you're logged in) ++ directly download posts ++ choose between two pfp (profile picture) viewer methods ++ fixed search box not showing up when toolbar is at bottom ++ automatically checks for updates + +v2.0 ++ fixed Login crashes + +v1.0 ++ first ever changelog ++ basic stuff like downloading profile pics, posts and copying captions and bio. ++ batch download posts diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..e587591 --- /dev/null +++ b/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md index 765a98f..0d5a5eb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ # Instabar -An instagram client, based on Barinsta \ No newline at end of file +[![Open Source Love](https://badges.frapsoft.com/os/v3/open-source.svg?v=103)](https://github.com/ellerbrock/open-source-badges/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://makeapullrequest.com) +[![GPLv3 license](https://img.shields.io/badge/License-GPLv3-blue.svg)](./LICENSE) + +Instagram client; based on Barinsta. + +## License + +This app's predecessor, Barinsta, was originally made by [Austin Huang](https://github.com/austinhuang) on GitHub. + + Instabar + Copyright (C) 2020-2021 Suhan Paradkar <12suhangp34@gmaail.com> + + This program is free software: you can redistribute it and/or modify + it under the terms of the 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 . diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..93b6c4e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1 @@ +If there is a security issue with the latest version, please let me know using GitHub issues (bug), or email `im [at] austinhuang [dot] me` if confidential. Use [this PGP key](https://github.com/austinhuang0131/austinhuang0131.github.io/blob/master/assets/key.asc) if you know how to. diff --git a/app/.classpath b/app/.classpath new file mode 100644 index 0000000..32d6691 --- /dev/null +++ b/app/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100755 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/.project b/app/.project new file mode 100644 index 0000000..4f45750 --- /dev/null +++ b/app/.project @@ -0,0 +1,34 @@ + + + app + Project app created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + + + 1600117114944 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/app/.settings/org.eclipse.buildship.core.prefs b/app/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..b1886ad --- /dev/null +++ b/app/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=.. +eclipse.preferences.version=1 diff --git a/app/build.gradle b/app/build.gradle new file mode 100755 index 0000000..7f99b91 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,262 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: "androidx.navigation.safeargs" +apply plugin: 'kotlin-kapt' +apply from: 'sentry.gradle' + +def getGitHash = { -> + def stdout = new ByteArrayOutputStream() + exec { + commandLine 'git', 'rev-parse', '--short', 'HEAD' + standardOutput = stdout + } + return stdout.toString().trim() +} + +android { + compileSdkVersion 30 + + defaultConfig { + applicationId 'awais.instagrabber' + + minSdkVersion 21 + targetSdkVersion 30 + + versionCode 65 + versionName '19.2.4' + + multiDexEnabled true + + vectorDrawables.useSupportLibrary = true + vectorDrawables.generatedDensities = [] + + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } + + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + + targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { viewBinding true } + + aaptOptions { additionalParameters '--no-version-vectors' } + + buildTypes { + debug { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + flavorDimensions "repo" + + productFlavors { + github { + dimension "repo" + // versionNameSuffix "-github" // appended in assemble task + buildConfigField("String", "dsn", SENTRY_DSN) + buildConfigField("boolean", "isPre", "false") + } + + fdroid { + dimension "repo" + versionNameSuffix "-fdroid" + buildConfigField("boolean", "isPre", "false") + } + } + + splits { + // Configures multiple APKs based on ABI. + abi { + // Enables building multiple APKs per ABI. + enable project.hasProperty("split") && !gradle.startParameter.taskNames.isEmpty() && gradle.startParameter.taskNames.get(0).contains('Release') + + // By default all ABIs are included, so use reset() and include to specify that we only + // want APKs for x86 and x86_64. + + // Resets the list of ABIs that Gradle should create APKs for to none. + reset() + + // Specifies a list of ABIs that Gradle should create APKs for. + include "x86", "x86_64", "arm64-v8a", "armeabi-v7a" + + // Specifies that we want to also generate a universal APK that includes all ABIs. + universalApk true + } + } + + + android.applicationVariants.all { variant -> + if (variant.flavorName != "github") return + variant.outputs.all { output -> + def builtType = variant.buildType.name + def versionName = variant.versionName + // def versionCode = variant.versionCode + def flavor = variant.flavorName + + def flavorBuiltType = "${flavor}_${builtType}" + def suffix + // For x86 and x86_64, the versionNames are already overridden + if (versionName.contains(flavorBuiltType)) { + suffix = "${versionName}" + } else { + suffix = "${versionName}-${flavorBuiltType}" // eg. 19.1.0-github_debug or release + } + if (builtType.toString() == 'release' && project.hasProperty("pre")) { + buildConfigField("boolean", "isPre", "true") + + flavorBuiltType = "${getGitHash()}-${flavor}" + + // For x86 and x86_64, the versionNames are already overridden + if (versionName.contains(flavorBuiltType)) { + suffix = "${versionName}" + } else { + // append latest commit short hash for pre-release + suffix = "${versionName}.${flavorBuiltType}" // eg. 19.1.0.b123456-github + } + } + + output.versionNameOverride = suffix + def abi = output.getFilter(com.android.build.OutputFile.ABI) + // println(abi + ", " + versionName + ", " + flavor + ", " + builtType + ", " + suffix) + outputFileName = abi == null ? "barinsta_${suffix}.apk" : "barinsta_${suffix}_${abi}.apk" + } + } + + packagingOptions { + // Exclude file to avoid + // Error: Duplicate files during packaging of APK + exclude 'META-INF/LICENSE.md' + exclude 'META-INF/LICENSE-notice.md' + exclude 'META-INF/atomicfu.kotlin_module' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LGPL2.1' + } + + testOptions.unitTests { + includeAndroidResources = true + } + +} + +configurations.all { + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + + def exoplayer_version = '2.14.1' + + implementation 'com.google.android.material:material:1.4.0' + + implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" + implementation "com.google.android.exoplayer:exoplayer-dash:$exoplayer_version" + implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" + + implementation "androidx.recyclerview:recyclerview:1.2.1" + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation "androidx.viewpager2:viewpager2:1.0.0" + implementation "androidx.constraintlayout:constraintlayout:2.0.4" + implementation "androidx.preference:preference:1.1.1" + implementation 'androidx.palette:palette:1.0.0' + implementation 'androidx.documentfile:documentfile:1.0.1' + + implementation 'com.google.guava:guava:27.0.1-android' + + def core_version = "1.6.0" + implementation "androidx.core:core:$core_version" + + // Fragment + implementation "androidx.fragment:fragment-ktx:1.3.5" + + // Lifecycle + implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" + + // Navigation + implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.nav_version" + implementation "androidx.navigation:navigation-ui-ktx:$rootProject.nav_version" + + // Room + def room_version = "2.3.0" + implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-guava:$room_version" + implementation "androidx.room:room-ktx:$room_version" + kapt "androidx.room:room-compiler:$room_version" + annotationProcessor "androidx.room:room-compiler:$room_version" + + // CameraX + def camerax_version = "1.1.0-alpha06" + implementation "androidx.camera:camera-camera2:$camerax_version" + implementation "androidx.camera:camera-lifecycle:$camerax_version" + implementation "androidx.camera:camera-view:1.0.0-alpha26" + + // EmojiCompat + def emoji_compat_version = "1.1.0" + implementation "androidx.emoji:emoji:$emoji_compat_version" + implementation "androidx.emoji:emoji-appcompat:$emoji_compat_version" + + // Work + def work_version = '2.5.0' + implementation "androidx.work:work-runtime:$work_version" + implementation "androidx.work:work-runtime-ktx:$work_version" + + implementation "ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0" + + implementation 'com.facebook.fresco:fresco:2.5.0' + implementation 'com.facebook.fresco:animated-webp:2.5.0' + implementation 'com.facebook.fresco:webpsupport:2.5.0' + + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-scalars:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + + implementation 'org.apache.commons:commons-imaging:1.0-alpha2' + + implementation 'com.github.skydoves:balloon:1.3.5' + + implementation 'com.github.ammargitham:AutoLinkTextViewV2:3.2.0' + implementation 'com.github.ammargitham:uCrop:2.3-non-native' + implementation 'com.github.ammargitham:android-gpuimage:2.1.1-beta4' + + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' + + githubImplementation 'io.sentry:sentry-android:5.0.1' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' + testImplementation "androidx.test.ext:junit-ktx:1.1.3" + testImplementation "androidx.test:core-ktx:1.4.0" + testImplementation "androidx.arch.core:core-testing:2.1.0" + testImplementation "org.robolectric:robolectric:4.5.1" + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' + + androidTestImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' + androidTestImplementation 'androidx.test:core:1.4.0' + androidTestImplementation 'com.android.support:support-annotations:28.0.0' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation "androidx.room:room-testing:2.3.0" + androidTestImplementation "androidx.arch.core:core-testing:2.1.0" + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' + +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100755 index 0000000..8fb95bd --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/local.properties b/app/local.properties new file mode 100644 index 0000000..2ed802a --- /dev/null +++ b/app/local.properties @@ -0,0 +1,2 @@ +sdk.dir=/opt/android-sdk +sdk-location=/opt/android-sdk diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100755 index 0000000..ff15034 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,29 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +#noinspection ShrinkerUnresolvedReference +#-keep class !com.google.android.exoplayer2.**, ** { *; } + +-dontobfuscate + +# prevent shrinking retrofit response entities +-keep class awais.instagrabber.repositories.responses.** { *; } \ No newline at end of file diff --git a/app/schemas/awais.instagrabber.db.AppDatabase/4.json b/app/schemas/awais.instagrabber.db.AppDatabase/4.json new file mode 100644 index 0000000..528d203 --- /dev/null +++ b/app/schemas/awais.instagrabber.db.AppDatabase/4.json @@ -0,0 +1,114 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "538d64adaeb8c3a98db9204955932e59", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fullName", + "columnName": "full_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePic", + "columnName": "profile_pic", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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, '538d64adaeb8c3a98db9204955932e59')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/awais.instagrabber.db.AppDatabase/5.json b/app/schemas/awais.instagrabber.db.AppDatabase/5.json new file mode 100644 index 0000000..a60a7ea --- /dev/null +++ b/app/schemas/awais.instagrabber.db.AppDatabase/5.json @@ -0,0 +1,161 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "0b38e12b76bb081ec837191c5ef5b54e", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fullName", + "columnName": "full_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePic", + "columnName": "profile_pic", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dm_last_notified", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `thread_id` TEXT, `last_notified_msg_ts` INTEGER, `last_notified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "thread_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastNotifiedMsgTs", + "columnName": "last_notified_msg_ts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastNotifiedAt", + "columnName": "last_notified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_dm_last_notified_thread_id", + "unique": true, + "columnNames": [ + "thread_id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `${TABLE_NAME}` (`thread_id`)" + } + ], + "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, '0b38e12b76bb081ec837191c5ef5b54e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/awais.instagrabber.db.AppDatabase/6.json b/app/schemas/awais.instagrabber.db.AppDatabase/6.json new file mode 100644 index 0000000..4a2f519 --- /dev/null +++ b/app/schemas/awais.instagrabber.db.AppDatabase/6.json @@ -0,0 +1,227 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "232e618b3bfcb4661336b359d036c455", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fullName", + "columnName": "full_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePic", + "columnName": "profile_pic", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dm_last_notified", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `thread_id` TEXT, `last_notified_msg_ts` INTEGER, `last_notified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "thread_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastNotifiedMsgTs", + "columnName": "last_notified_msg_ts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastNotifiedAt", + "columnName": "last_notified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_dm_last_notified_thread_id", + "unique": true, + "columnNames": [ + "thread_id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `${TABLE_NAME}` (`thread_id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "recent_searches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ig_id` TEXT NOT NULL, `name` TEXT NOT NULL, `username` TEXT, `pic_url` TEXT, `type` TEXT NOT NULL, `last_searched_on` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "igId", + "columnName": "ig_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSearchedOn", + "columnName": "last_searched_on", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_recent_searches_ig_id_type", + "unique": true, + "columnNames": [ + "ig_id", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_searches_ig_id_type` ON `${TABLE_NAME}` (`ig_id`, `type`)" + } + ], + "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, '232e618b3bfcb4661336b359d036c455')" + ] + } +} \ No newline at end of file diff --git a/app/sentry.gradle b/app/sentry.gradle new file mode 100644 index 0000000..f2b24aa --- /dev/null +++ b/app/sentry.gradle @@ -0,0 +1,13 @@ +def dsnKey = 'DSN' +def defaultDsn = '\"\"' + +final Properties properties = new Properties() +File propertiesFile = rootProject.file('sentry.properties') +if (!propertiesFile.exists()) { + propertiesFile.createNewFile() +} +properties.load(new FileInputStream(propertiesFile)) + +ext{ + SENTRY_DSN = properties.getProperty(dsnKey, defaultDsn) +} \ No newline at end of file diff --git a/app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java b/app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java new file mode 100644 index 0000000..2778412 --- /dev/null +++ b/app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java @@ -0,0 +1,50 @@ +package awais.instagrabber.db; + +import androidx.room.Room; +import androidx.room.migration.Migration; +import androidx.room.testing.MigrationTestHelper; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +import static awais.instagrabber.db.AppDatabase.MIGRATION_4_5; +import static awais.instagrabber.db.AppDatabase.MIGRATION_5_6; + +@RunWith(AndroidJUnit4.class) +public class MigrationTest { + private static final String TEST_DB = "migration-test"; + private static final Migration[] ALL_MIGRATIONS = new Migration[]{MIGRATION_4_5, MIGRATION_5_6}; + + @Rule + public MigrationTestHelper helper; + + public MigrationTest() { + final String canonicalName = AppDatabase.class.getCanonicalName(); + helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), + canonicalName, + new FrameworkSQLiteOpenHelperFactory()); + } + + @Test + public void migrateAll() throws IOException { + // Create earliest version of the database. Have to start with 4 since that is the version we migrated to Room. + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 4); + db.close(); + + // Open latest version of the database. Room will validate the schema + // once all migrations execute. + AppDatabase appDb = Room.databaseBuilder(InstrumentationRegistry.getInstrumentation().getTargetContext(), + AppDatabase.class, + TEST_DB) + .addMigrations(ALL_MIGRATIONS).build(); + appDb.getOpenHelper().getWritableDatabase(); + appDb.close(); + } +} diff --git a/app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.kt b/app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.kt new file mode 100644 index 0000000..0292f18 --- /dev/null +++ b/app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.kt @@ -0,0 +1,81 @@ +package awais.instagrabber.db.dao + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.runner.AndroidJUnit4 +import awais.instagrabber.db.AppDatabase +import awais.instagrabber.db.entities.RecentSearch +import awais.instagrabber.models.enums.FavoriteType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions +import org.junit.runner.RunWith +import java.time.LocalDateTime + +@RunWith(AndroidJUnit4::class) +class RecentSearchDaoTest { + private lateinit var db: AppDatabase + private lateinit var dao: RecentSearchDao + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + dao = db.recentSearchDao() + } + + @After + fun closeDb() { + db.close() + } + + @ExperimentalCoroutinesApi + @Test + fun writeQueryDelete() = runBlockingTest { + val recentSearch = insertRecentSearch(1, "1", "test1", FavoriteType.HASHTAG) + val byIgIdAndType = dao.getRecentSearchByIgIdAndType("1", FavoriteType.HASHTAG) + Assertions.assertNotNull(byIgIdAndType) + Assertions.assertEquals(recentSearch, byIgIdAndType) + dao.deleteRecentSearch(byIgIdAndType ?: throw NullPointerException()) + val deleted = dao.getRecentSearchByIgIdAndType("1", FavoriteType.HASHTAG) + Assertions.assertNull(deleted) + } + + @ExperimentalCoroutinesApi + @Test + fun queryAllOrdered() = runBlockingTest { + val insertListReversed: List = listOf( + insertRecentSearch(1, "1", "test1", FavoriteType.HASHTAG), + insertRecentSearch(2, "2", "test2", FavoriteType.LOCATION), + insertRecentSearch(3, "3", "test3", FavoriteType.USER), + insertRecentSearch(4, "4", "test4", FavoriteType.USER), + insertRecentSearch(5, "5", "test5", FavoriteType.USER) + ).asReversed() // important + val fromDb: List = dao.getAllRecentSearches() + Assertions.assertIterableEquals(insertListReversed, fromDb) + } + + private fun insertRecentSearch(id: Int, igId: String, name: String, type: FavoriteType): RecentSearch { + val recentSearch = RecentSearch( + id, + igId, + name, + null, + null, + type, + LocalDateTime.now() + ) + runBlocking { dao.insertRecentSearch(recentSearch) } + return recentSearch + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/awaisomereport/CrashReporterHelperTest.kt b/app/src/androidTest/java/awaisomereport/CrashReporterHelperTest.kt new file mode 100644 index 0000000..62b1368 --- /dev/null +++ b/app/src/androidTest/java/awaisomereport/CrashReporterHelperTest.kt @@ -0,0 +1,15 @@ +package awaisomereport + +import androidx.test.runner.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class CrashReporterHelperTest { + + @Test + fun getErrorContent() { + val errorContent = CrashReporterHelper.getReportContent(Exception()) + print(errorContent) + } +} \ No newline at end of file diff --git a/app/src/fdroid/java/awais/instagrabber/fragments/settings/FlavorSettings.java b/app/src/fdroid/java/awais/instagrabber/fragments/settings/FlavorSettings.java new file mode 100644 index 0000000..b30e263 --- /dev/null +++ b/app/src/fdroid/java/awais/instagrabber/fragments/settings/FlavorSettings.java @@ -0,0 +1,40 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; +import androidx.preference.Preference; + +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.fragments.settings.IFlavorSettings; +import awais.instagrabber.fragments.settings.SettingCategory; + +public final class FlavorSettings implements IFlavorSettings { + + private static FlavorSettings instance; + + private FlavorSettings() { + } + + public static FlavorSettings getInstance() { + if (instance == null) { + instance = new FlavorSettings(); + } + return instance; + } + + @NonNull + @Override + public List getPreferences(@NonNull final Context context, + @NonNull final FragmentManager fragmentManager, + @NonNull final SettingCategory settingCategory) { + // switch (settingCategory) { + // default: + // break; + // } + return Collections.emptyList(); + } +} diff --git a/app/src/fdroid/java/awais/instagrabber/utils/UpdateChecker.java b/app/src/fdroid/java/awais/instagrabber/utils/UpdateChecker.java new file mode 100644 index 0000000..f25b8aa --- /dev/null +++ b/app/src/fdroid/java/awais/instagrabber/utils/UpdateChecker.java @@ -0,0 +1,64 @@ +package awais.instagrabber.utils; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import org.json.JSONObject; + +import java.net.HttpURLConnection; +import java.net.URL; + +public class UpdateChecker { + private static final Object LOCK = new Object(); + private static final String TAG = UpdateChecker.class.getSimpleName(); + + private static UpdateChecker instance; + + public static UpdateChecker getInstance() { + if (instance == null) { + synchronized (LOCK) { + if (instance == null) { + instance = new UpdateChecker(); + } + } + } + return instance; + } + + /** + * Needs to be called asynchronously + * + * @return the latest version from f-droid + */ + @Nullable + public String getLatestVersion() { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL("https://f-droid.org/api/v1/packages/me.austinhuang.instagrabber").openConnection(); + conn.setUseCaches(false); + conn.setRequestProperty("User-Agent", "https://Barinsta.AustinHuang.me / mailto:Barinsta@AustinHuang.me"); + conn.connect(); + final int responseCode = conn.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + final JSONObject data = new JSONObject(NetworkUtils.readFromConnection(conn)); + return "v" + data.getJSONArray("packages").getJSONObject(0).getString("versionName"); + // if (BuildConfig.VERSION_CODE < data.getInt("suggestedVersionCode")) { + // } + } + } catch (final Exception e) { + Log.e(TAG, "", e); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return null; + } + + public void onDownload(@NonNull final AppCompatActivity context) { + Utils.openURL(context, "https://f-droid.org/packages/me.austinhuang.instagrabber/"); + } +} diff --git a/app/src/fdroid/java/awaisomereport/CrashHandler.kt b/app/src/fdroid/java/awaisomereport/CrashHandler.kt new file mode 100644 index 0000000..02f28fa --- /dev/null +++ b/app/src/fdroid/java/awaisomereport/CrashHandler.kt @@ -0,0 +1,14 @@ +package awaisomereport + +import android.app.Application + +class CrashHandler(private val application: Application) : ICrashHandler { + override fun uncaughtException( + t: Thread, + exception: Throwable, + defaultExceptionHandler: Thread.UncaughtExceptionHandler + ) { + CrashReporterHelper.startErrorReporterActivity(application, exception) + defaultExceptionHandler.uncaughtException(t, exception) + } +} \ No newline at end of file diff --git a/app/src/github/AndroidManifest.xml b/app/src/github/AndroidManifest.xml new file mode 100644 index 0000000..4c6a6fe --- /dev/null +++ b/app/src/github/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/github/java/awais/instagrabber/fragments/settings/FlavorSettings.java b/app/src/github/java/awais/instagrabber/fragments/settings/FlavorSettings.java new file mode 100644 index 0000000..85adb8a --- /dev/null +++ b/app/src/github/java/awais/instagrabber/fragments/settings/FlavorSettings.java @@ -0,0 +1,83 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; +import androidx.preference.Preference; + +import com.google.common.collect.ImmutableList; + +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.ConfirmDialogFragment; + +import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_ENABLE_SENTRY; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public final class FlavorSettings implements IFlavorSettings { + + private static FlavorSettings instance; + + private FlavorSettings() { + } + + public static FlavorSettings getInstance() { + if (instance == null) { + instance = new FlavorSettings(); + } + return instance; + } + + @NonNull + @Override + public List getPreferences(@NonNull final Context context, + @NonNull final FragmentManager fragmentManager, + @NonNull final SettingCategory settingCategory) { + switch (settingCategory) { + case GENERAL: + return getGeneralPrefs(context, fragmentManager); + default: + break; + } + return Collections.emptyList(); + } + + private List getGeneralPrefs(@NonNull final Context context, + @NonNull final FragmentManager fragmentManager) { + return ImmutableList.of( + getSentryPreference(context, fragmentManager) + ); + } + + private Preference getSentryPreference(@NonNull final Context context, + @NonNull final FragmentManager fragmentManager) { + if (!settingsHelper.hasPreference(PREF_ENABLE_SENTRY)) { + // disabled by default + settingsHelper.putBoolean(PREF_ENABLE_SENTRY, false); + } + return PreferenceHelper.getSwitchPreference( + context, + PREF_ENABLE_SENTRY, + R.string.enable_sentry, + R.string.sentry_summary, + false, + (preference, newValue) -> { + if (!(newValue instanceof Boolean)) return true; + final boolean enabled = (Boolean) newValue; + if (enabled) { + final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( + 111, + 0, + R.string.sentry_start_next_launch, + R.string.ok, + 0, + 0); + dialogFragment.show(fragmentManager, "sentry_dialog"); + } + return true; + }); + } +} diff --git a/app/src/github/java/awais/instagrabber/utils/UpdateChecker.java b/app/src/github/java/awais/instagrabber/utils/UpdateChecker.java new file mode 100644 index 0000000..70bc269 --- /dev/null +++ b/app/src/github/java/awais/instagrabber/utils/UpdateChecker.java @@ -0,0 +1,61 @@ +package awais.instagrabber.utils; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.net.HttpURLConnection; +import java.net.URL; + +public class UpdateChecker { + private static final Object LOCK = new Object(); + private static final String TAG = UpdateChecker.class.getSimpleName(); + + private static UpdateChecker instance; + + public static UpdateChecker getInstance() { + if (instance == null) { + synchronized (LOCK) { + if (instance == null) { + instance = new UpdateChecker(); + } + } + } + return instance; + } + + /** + * Needs to be called asynchronously + * + * @return the latest version from Github + */ + @Nullable + public String getLatestVersion() { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL("https://github.com/austinhuang0131/barinsta/releases/latest").openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setUseCaches(false); + conn.setRequestProperty("User-Agent", "https://Barinsta.AustinHuang.me / mailto:Barinsta@AustinHuang.me"); + conn.connect(); + final int responseCode = conn.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_MOVED_TEMP) { + return "v" + conn.getHeaderField("Location").split("/v")[1]; + // return !version.equals(BuildConfig.VERSION_NAME); + } + } catch (final Exception e) { + Log.e(TAG, "", e); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return null; + } + + public void onDownload(@NonNull final Context context) { + Utils.openURL(context, "https://github.com/austinhuang0131/instagrabber/releases/latest"); + } +} diff --git a/app/src/github/java/awaisomereport/CrashHandler.kt b/app/src/github/java/awaisomereport/CrashHandler.kt new file mode 100644 index 0000000..66484dc --- /dev/null +++ b/app/src/github/java/awaisomereport/CrashHandler.kt @@ -0,0 +1,54 @@ +package awaisomereport + +import android.app.Application +import awais.instagrabber.BuildConfig +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.utils.Utils +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.SentryOptions.BeforeSendCallback +import io.sentry.android.core.SentryAndroid +import io.sentry.android.core.SentryAndroidOptions + +class CrashHandler(private val application: Application) : ICrashHandler { + private var enabled = false + + init { + enabled = if (!Utils.settingsHelper.hasPreference(PreferenceKeys.PREF_ENABLE_SENTRY)) { + // disabled by default (change to true if we need enabled by default) + false + } else { + Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_SENTRY) + } + if (enabled) { + SentryAndroid.init(application) { options: SentryAndroidOptions -> + options.dsn = BuildConfig.dsn + options.setDiagnosticLevel(SentryLevel.ERROR) + options.beforeSend = BeforeSendCallback { event: SentryEvent, _: Any? -> + // Removing unneeded info from event + event.contexts.device?.apply { + name = null + timezone = null + isCharging = null + bootTime = null + freeStorage = null + batteryTemperature = null + } + event + } + } + } + } + + override fun uncaughtException( + t: Thread, + exception: Throwable, + defaultExceptionHandler: Thread.UncaughtExceptionHandler + ) { + // When enabled, Sentry auto captures unhandled exceptions + if (!enabled) { + CrashReporterHelper.startErrorReporterActivity(application, exception) + } + defaultExceptionHandler.uncaughtException(t, exception) + } +} \ No newline at end of file diff --git a/app/src/github/res/values-ar/strings.xml b/app/src/github/res/values-ar/strings.xml new file mode 100644 index 0000000..6cddd43 --- /dev/null +++ b/app/src/github/res/values-ar/strings.xml @@ -0,0 +1,6 @@ + + + تمكين الحراسة + الحراسة هي مستمع/معالج للأخطاء الذي يرسل الخطأ/الاحداث إلى Sentry.io + ستبدأ الحراسة عند التشغيل التالي + diff --git a/app/src/github/res/values-ca/strings.xml b/app/src/github/res/values-ca/strings.xml new file mode 100644 index 0000000..599c258 --- /dev/null +++ b/app/src/github/res/values-ca/strings.xml @@ -0,0 +1,6 @@ + + + Habilita el Sentry + Sentry és un oient/intèrpret d\'error que envia asíncronament l\'error/esdeveniment a Sentry.io + Sentry s\'iniciarà al pròxim llançament + diff --git a/app/src/github/res/values-cs/strings.xml b/app/src/github/res/values-cs/strings.xml new file mode 100644 index 0000000..f22f68c --- /dev/null +++ b/app/src/github/res/values-cs/strings.xml @@ -0,0 +1,6 @@ + + + Povolit Sentry + Sentry je listener/handler, který zaznamenává chyby a asynchronně je posílá na Sentry.io + Sentry se spustí při příštím spuštění + diff --git a/app/src/github/res/values-de/strings.xml b/app/src/github/res/values-de/strings.xml new file mode 100644 index 0000000..3833166 --- /dev/null +++ b/app/src/github/res/values-de/strings.xml @@ -0,0 +1,6 @@ + + + Aktiviere Sentry + Sentry ist ein Listener/Handler für Fehler, der den Fehler/das Ereignis asynchron an Sentry.io sendet + Sentry startet beim nächsten Start + diff --git a/app/src/github/res/values-el/strings.xml b/app/src/github/res/values-el/strings.xml new file mode 100644 index 0000000..579b3cc --- /dev/null +++ b/app/src/github/res/values-el/strings.xml @@ -0,0 +1,6 @@ + + + Ενεργοποίηση Sentry + Το Sentry είναι διαχειριστής σφαλμάτων ασύγχρονης αποστολής του σφάλματος/συμβάντος στο Sentry.io + Το Sentry θα ξεκινήσει στην επόμενη εκκίνηση + diff --git a/app/src/github/res/values-es/strings.xml b/app/src/github/res/values-es/strings.xml new file mode 100644 index 0000000..59f6cc2 --- /dev/null +++ b/app/src/github/res/values-es/strings.xml @@ -0,0 +1,6 @@ + + + Activar Sentry + Sentry es un oyente/manejador de errores que asincrónicamente envía el error/evento a Sentry.io + Sentry comenzará en el próximo inicio + diff --git a/app/src/github/res/values-eu/strings.xml b/app/src/github/res/values-eu/strings.xml new file mode 100644 index 0000000..078fa6a --- /dev/null +++ b/app/src/github/res/values-eu/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + diff --git a/app/src/github/res/values-fa/strings.xml b/app/src/github/res/values-fa/strings.xml new file mode 100644 index 0000000..30d3ec3 --- /dev/null +++ b/app/src/github/res/values-fa/strings.xml @@ -0,0 +1,6 @@ + + + فعالسازی Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry در اجرای بعدی، شروع خواهد شد + diff --git a/app/src/github/res/values-fr/strings.xml b/app/src/github/res/values-fr/strings.xml new file mode 100644 index 0000000..74186f6 --- /dev/null +++ b/app/src/github/res/values-fr/strings.xml @@ -0,0 +1,6 @@ + + + Activer Sentry + Sentry est un écouteur/gestionnaire d\'erreurs qui envoie de manière asynchrone l\'erreur/l\'événement à Sentry.io + Sentry commencera au prochain lancement + diff --git a/app/src/github/res/values-hi/strings.xml b/app/src/github/res/values-hi/strings.xml new file mode 100644 index 0000000..078fa6a --- /dev/null +++ b/app/src/github/res/values-hi/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + diff --git a/app/src/github/res/values-in/strings.xml b/app/src/github/res/values-in/strings.xml new file mode 100644 index 0000000..7ae8dee --- /dev/null +++ b/app/src/github/res/values-in/strings.xml @@ -0,0 +1,6 @@ + + + Hidupkan Sentry + Sentry adalah sebuah pendengar/penanganan eror yang secara asinkronis mengirimkan eror/kejadian ke Sentry.io + Sentry akan dihidupkan pada peluncuran berikutnya + diff --git a/app/src/github/res/values-it/strings.xml b/app/src/github/res/values-it/strings.xml new file mode 100644 index 0000000..99ec0ec --- /dev/null +++ b/app/src/github/res/values-it/strings.xml @@ -0,0 +1,6 @@ + + + Abilita Sentry + Sentry è un ascoltatore/gestore di errori che invia asincronicamente l\'errore/evento a Sentry.io + Sentry comincerà al prossimo lancio + diff --git a/app/src/github/res/values-ja/strings.xml b/app/src/github/res/values-ja/strings.xml new file mode 100644 index 0000000..078fa6a --- /dev/null +++ b/app/src/github/res/values-ja/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + diff --git a/app/src/github/res/values-ko/strings.xml b/app/src/github/res/values-ko/strings.xml new file mode 100644 index 0000000..272483b --- /dev/null +++ b/app/src/github/res/values-ko/strings.xml @@ -0,0 +1,6 @@ + + + Sentry 활성화 + Sentry는 Sentry.io에 오류를 비동기적으로 보내는 오류 처리기입니다 + Sentry는 다음 출시에 시작됩니다 + diff --git a/app/src/github/res/values-mk/strings.xml b/app/src/github/res/values-mk/strings.xml new file mode 100644 index 0000000..1ef096e --- /dev/null +++ b/app/src/github/res/values-mk/strings.xml @@ -0,0 +1,6 @@ + + + Овозможи Sentry + Sentry е слушач на грешки кој асинхроно ги испраќа на Sentry.io страната + Sentry ќе биде овозможен на следно отварање + diff --git a/app/src/github/res/values-nl/strings.xml b/app/src/github/res/values-nl/strings.xml new file mode 100644 index 0000000..e043c9b --- /dev/null +++ b/app/src/github/res/values-nl/strings.xml @@ -0,0 +1,6 @@ + + + Sentry inschakelen + Sentry is een luister/handler voor fouten die asynchroon de fout/gebeurtenis versturen naar Sentry.io + Sentry zal starten bij de volgende lancering + diff --git a/app/src/github/res/values-or/strings.xml b/app/src/github/res/values-or/strings.xml new file mode 100644 index 0000000..173bf73 --- /dev/null +++ b/app/src/github/res/values-or/strings.xml @@ -0,0 +1,6 @@ + + + Sentryକୁ ସକ୍ଷମ କରନ୍ତୁ + ତ୍ରୁଟି ପାଇଁ ସେଣ୍ଟ୍ରି ହେଉଛି ଏକ ଶ୍ରୋତା ଯାହା ତ୍ରୁଟି / ଘଟଣାକୁ Sentry.io କୁ ପଠାଏ | + ପରବର୍ତ୍ତୀ ଲଞ୍ଚଠାରୁ ସେଣ୍ଟ୍ରି ଆରମ୍ଭ ହେବ | + diff --git a/app/src/github/res/values-pl/strings.xml b/app/src/github/res/values-pl/strings.xml new file mode 100644 index 0000000..08b8c67 --- /dev/null +++ b/app/src/github/res/values-pl/strings.xml @@ -0,0 +1,6 @@ + + + Włącz Sentry + Sentry jest słuchaczem/obsługą błędów, które asynchronicznie wysyłają błąd/zdarzenie do Sentry.io + Sentry rozpocznie się przy następnym uruchomieniu + diff --git a/app/src/github/res/values-pt/strings.xml b/app/src/github/res/values-pt/strings.xml new file mode 100644 index 0000000..7a0848a --- /dev/null +++ b/app/src/github/res/values-pt/strings.xml @@ -0,0 +1,6 @@ + + + Ativar Sentry + Sentry é um ouvinte/gestor de erros que assincronicamente envia o erro/evento para Sentry.io + Sentry começará no próximo início + diff --git a/app/src/github/res/values-ru/strings.xml b/app/src/github/res/values-ru/strings.xml new file mode 100644 index 0000000..ecd5d23 --- /dev/null +++ b/app/src/github/res/values-ru/strings.xml @@ -0,0 +1,6 @@ + + + Включить Sentry + Sentry - это обработчик событий, который асинхронно отправляет сообщения об ошибках/поломках на Sentry.io + Sentry включится при следующем запуске приложения + diff --git a/app/src/github/res/values-sk/strings.xml b/app/src/github/res/values-sk/strings.xml new file mode 100644 index 0000000..078fa6a --- /dev/null +++ b/app/src/github/res/values-sk/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + diff --git a/app/src/github/res/values-sv/strings.xml b/app/src/github/res/values-sv/strings.xml new file mode 100644 index 0000000..078fa6a --- /dev/null +++ b/app/src/github/res/values-sv/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + diff --git a/app/src/github/res/values-tr/strings.xml b/app/src/github/res/values-tr/strings.xml new file mode 100644 index 0000000..078fa6a --- /dev/null +++ b/app/src/github/res/values-tr/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + diff --git a/app/src/github/res/values-vi/strings.xml b/app/src/github/res/values-vi/strings.xml new file mode 100644 index 0000000..8669fe9 --- /dev/null +++ b/app/src/github/res/values-vi/strings.xml @@ -0,0 +1,6 @@ + + + Bật Sentry + Sentry là một thiết bị nghe/giải quyết cho những lỗi mà gửi những lỗi/sự kiện đến Sentry.io một cách tách biệt + Sentry sẽ được bật vào lần khởi động kế tiếp + diff --git a/app/src/github/res/values-zh-rCN/strings.xml b/app/src/github/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..8c4a510 --- /dev/null +++ b/app/src/github/res/values-zh-rCN/strings.xml @@ -0,0 +1,6 @@ + + + 启用 Sentry + Sentry 会将错误报告发送至 Sentry.io + 启用 Sentry 将在下次启动应用时生效 + diff --git a/app/src/github/res/values-zh-rTW/strings.xml b/app/src/github/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..311b8b3 --- /dev/null +++ b/app/src/github/res/values-zh-rTW/strings.xml @@ -0,0 +1,6 @@ + + + 啟用 Sentry + Sentry 會將錯誤報告發送至 Sentry.io + 下次啟用應用程式時將會開啟 Sentry + diff --git a/app/src/github/res/values/strings.xml b/app/src/github/res/values/strings.xml new file mode 100644 index 0000000..6bc4c7a --- /dev/null +++ b/app/src/github/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Enable Sentry + Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io + Sentry will start on next launch + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100755 index 0000000..e6d9dcc --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..3e3d81d Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/ic_launcher-round.png b/app/src/main/ic_launcher-round.png new file mode 100644 index 0000000..5ad57b2 Binary files /dev/null and b/app/src/main/ic_launcher-round.png differ diff --git a/app/src/main/java/awais/instagrabber/InstaGrabberApplication.kt b/app/src/main/java/awais/instagrabber/InstaGrabberApplication.kt new file mode 100644 index 0000000..9e791bc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/InstaGrabberApplication.kt @@ -0,0 +1,70 @@ +package awais.instagrabber + +import android.app.Application +import android.content.ClipboardManager +import android.util.Log +import awais.instagrabber.fragments.settings.PreferenceKeys.CUSTOM_DATE_TIME_FORMAT +import awais.instagrabber.fragments.settings.PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED +import awais.instagrabber.fragments.settings.PreferenceKeys.DATE_TIME_FORMAT +import awais.instagrabber.utils.* +import awais.instagrabber.utils.LocaleUtils.currentLocale +import awais.instagrabber.utils.Utils.settingsHelper +import awais.instagrabber.utils.extensions.TAG +import awaisomereport.CrashReporter +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.core.ImagePipelineConfig +import java.net.CookieHandler +import java.time.format.DateTimeFormatter +import java.util.* + +@Suppress("unused") +class InstaGrabberApplication : Application() { + override fun onCreate() { + super.onCreate() + CookieHandler.setDefault(NET_COOKIE_MANAGER) + settingsHelper = SettingsHelper(this) + setupCrashReporter() + setupCloseGuard() + setupFresco() + Utils.cacheDir = cacheDir.absolutePath + Utils.clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + LocaleUtils.setLocale(baseContext) + val pattern = if (settingsHelper.getBoolean(CUSTOM_DATE_TIME_FORMAT_ENABLED)) { + settingsHelper.getString(CUSTOM_DATE_TIME_FORMAT) + } else { + settingsHelper.getString(DATE_TIME_FORMAT) + } + TextUtils.setFormatter(DateTimeFormatter.ofPattern(pattern, currentLocale)) + if (TextUtils.isEmpty(settingsHelper.getString(Constants.DEVICE_UUID))) { + settingsHelper.putString(Constants.DEVICE_UUID, UUID.randomUUID().toString()) + } + } + + private fun setupCrashReporter() { + if (BuildConfig.DEBUG) return + CrashReporter.getInstance(this).start() + } + + private fun setupCloseGuard() { + if (!BuildConfig.DEBUG) return + try { + Class.forName("dalvik.system.CloseGuard") + .getMethod("setEnabled", Boolean::class.javaPrimitiveType) + .invoke(null, true) + } catch (e: Exception) { + Log.e(TAG, "Error", e) + } + } + + private fun setupFresco() { + // final Set requestListeners = new HashSet<>(); + // requestListeners.add(new RequestLoggingListener()); + val imagePipelineConfig = ImagePipelineConfig + .newBuilder(this) // .setMainDiskCacheConfig(diskCacheConfig) + // .setRequestListeners(requestListeners) + .setDownsampleEnabled(true) + .build() + Fresco.initialize(this, imagePipelineConfig) + // FLog.setMinimumLoggingLevel(FLog.VERBOSE); + } +} diff --git a/app/src/main/java/awais/instagrabber/activities/BaseLanguageActivity.kt b/app/src/main/java/awais/instagrabber/activities/BaseLanguageActivity.kt new file mode 100755 index 0000000..f61171a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/activities/BaseLanguageActivity.kt @@ -0,0 +1,18 @@ +package awais.instagrabber.activities + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import awais.instagrabber.utils.LocaleUtils +import awais.instagrabber.utils.ThemeUtils + +abstract class BaseLanguageActivity protected constructor() : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + ThemeUtils.changeTheme(this) + super.onCreate(savedInstanceState) + } + + init { + @Suppress("LeakingThis") + LocaleUtils.updateConfig(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/activities/CameraActivity.kt b/app/src/main/java/awais/instagrabber/activities/CameraActivity.kt new file mode 100644 index 0000000..1ea124b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/activities/CameraActivity.kt @@ -0,0 +1,240 @@ +package awais.instagrabber.activities + +import android.content.Intent +import android.content.res.Configuration +import android.hardware.display.DisplayManager +import android.hardware.display.DisplayManager.DisplayListener +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile +import awais.instagrabber.databinding.ActivityCameraBinding +import awais.instagrabber.utils.DownloadUtils +import awais.instagrabber.utils.PermissionUtils +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.TAG +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class CameraActivity : BaseLanguageActivity() { + private lateinit var binding: ActivityCameraBinding + private lateinit var displayManager: DisplayManager + private lateinit var cameraExecutor: ExecutorService + + private var outputDirectory: DocumentFile? = null + private var imageCapture: ImageCapture? = null + private var displayId = -1 + private var cameraProvider: ProcessCameraProvider? = null + private var lensFacing = 0 + + private val cameraRequestCode = 100 + private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US) + private val displayListener: DisplayListener = object : DisplayListener { + override fun onDisplayAdded(displayId: Int) {} + override fun onDisplayRemoved(displayId: Int) {} + override fun onDisplayChanged(displayId: Int) { + if (displayId == this@CameraActivity.displayId) { + imageCapture?.targetRotation = binding.root.display.rotation + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityCameraBinding.inflate(LayoutInflater.from(baseContext)) + setContentView(binding.root) + Utils.transparentStatusBar(this, true, false) + displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager + outputDirectory = DownloadUtils.cameraDir + cameraExecutor = Executors.newSingleThreadExecutor() + displayManager.registerDisplayListener(displayListener, null) + binding.viewFinder.post { + displayId = binding.viewFinder.display.displayId + updateUi() + checkPermissionsAndSetupCamera() + } + } + + override fun onResume() { + super.onResume() + // Make sure that all permissions are still present, since the + // user could have removed them while the app was in paused state. + if (!PermissionUtils.hasCameraPerms(this)) { + PermissionUtils.requestCameraPerms(this, cameraRequestCode) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // Redraw the camera UI controls + updateUi() + + // Enable or disable switching between cameras + updateCameraSwitchButton() + } + + override fun onDestroy() { + super.onDestroy() + Utils.transparentStatusBar(this, false, false) + cameraExecutor.shutdown() + displayManager.unregisterDisplayListener(displayListener) + } + + private fun updateUi() { + binding.cameraCaptureButton.setOnClickListener { takePhoto() } + // Disable the button until the camera is set up + binding.switchCamera.isEnabled = false + // Listener for button used to switch cameras. Only called if the button is enabled + binding.switchCamera.setOnClickListener { + lensFacing = if (CameraSelector.LENS_FACING_FRONT == lensFacing) CameraSelector.LENS_FACING_BACK else CameraSelector.LENS_FACING_FRONT + // Re-bind use cases to update selected camera + bindCameraUseCases() + } + binding.close.setOnClickListener { + setResult(RESULT_CANCELED) + finish() + } + } + + private fun checkPermissionsAndSetupCamera() { + if (PermissionUtils.hasCameraPerms(this)) { + setupCamera() + return + } + PermissionUtils.requestCameraPerms(this, cameraRequestCode) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == cameraRequestCode) { + if (PermissionUtils.hasCameraPerms(this)) { + setupCamera() + } + } + } + + private fun setupCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(this) + cameraProviderFuture.addListener({ + try { + cameraProvider = cameraProviderFuture.get() + // Select lensFacing depending on the available cameras + lensFacing = -1 + if (hasBackCamera()) { + lensFacing = CameraSelector.LENS_FACING_BACK + } else if (hasFrontCamera()) { + lensFacing = CameraSelector.LENS_FACING_FRONT + } + check(lensFacing != -1) { "Back and front camera are unavailable" } + // Enable or disable switching between cameras + updateCameraSwitchButton() + // Build and bind the camera use cases + bindCameraUseCases() + } catch (e: ExecutionException) { + Log.e(TAG, "setupCamera: ", e) + } catch (e: InterruptedException) { + Log.e(TAG, "setupCamera: ", e) + } catch (e: CameraInfoUnavailableException) { + Log.e(TAG, "setupCamera: ", e) + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun bindCameraUseCases() { + val rotation = binding.viewFinder.display.rotation + + // CameraSelector + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + + // Preview + val preview = Preview.Builder() // Set initial target rotation + .setTargetRotation(rotation) + .build() + + // ImageCapture + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) // Set initial target rotation, we will have to call this again if rotation changes + // during the lifecycle of this use case + .setTargetRotation(rotation) + .build() + cameraProvider?.unbindAll() + cameraProvider?.bindToLifecycle(this, cameraSelector, preview, imageCapture) + preview.setSurfaceProvider(binding.viewFinder.surfaceProvider) + } + + private fun takePhoto() { + if (imageCapture == null) return + val fileName = simpleDateFormat.format(System.currentTimeMillis()) + ".jpg" + val mimeType = "image/jpg" + val photoFile = outputDirectory?.createFile(mimeType, fileName)?.let { it } ?: return + val outputStream = contentResolver.openOutputStream(photoFile.uri)?.let { it } ?: return + val outputFileOptions = ImageCapture.OutputFileOptions.Builder(outputStream).build() + imageCapture?.takePicture( + outputFileOptions, + cameraExecutor, + object : ImageCapture.OnImageSavedCallback { + @Suppress("UnstableApiUsage") + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + try { outputStream.close() } catch (ignored: IOException) {} + val intent = Intent() + intent.data = photoFile.uri + setResult(RESULT_OK, intent) + finish() + Log.d(TAG, "onImageSaved: " + photoFile.uri) + } + + override fun onError(exception: ImageCaptureException) { + Log.e(TAG, "onError: ", exception) + try { outputStream.close() } catch (ignored: IOException) {} + } + } + ) + // We can only change the foreground Drawable using API level 23+ API + // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // // Display flash animation to indicate that photo was captured + // final ConstraintLayout container = binding.getRoot(); + // container.postDelayed(() -> { + // container.setForeground(new ColorDrawable(Color.WHITE)); + // container.postDelayed(() -> container.setForeground(null), 50); + // }, 100); + // } + } + + /** + * Enabled or disabled a button to switch cameras depending on the available cameras + */ + private fun updateCameraSwitchButton() { + try { + binding.switchCamera.isEnabled = hasBackCamera() && hasFrontCamera() + } catch (e: CameraInfoUnavailableException) { + binding.switchCamera.isEnabled = false + } + } + + /** + * Returns true if the device has an available back camera. False otherwise + */ + @Throws(CameraInfoUnavailableException::class) + private fun hasBackCamera(): Boolean { + return if (cameraProvider == null) false else cameraProvider?.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ?: false + } + + /** + * Returns true if the device has an available front camera. False otherwise + */ + @Throws(CameraInfoUnavailableException::class) + private fun hasFrontCamera(): Boolean { + return if (cameraProvider == null) { + false + } else cameraProvider?.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) ?: false + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/activities/DirectorySelectActivity.java b/app/src/main/java/awais/instagrabber/activities/DirectorySelectActivity.java new file mode 100644 index 0000000..d5c316c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/activities/DirectorySelectActivity.java @@ -0,0 +1,129 @@ +package awais.instagrabber.activities; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.DocumentsContract; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.ActivityDirectorySelectBinding; +import awais.instagrabber.dialogs.ConfirmDialogFragment; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.viewmodels.DirectorySelectActivityViewModel; + +public class DirectorySelectActivity extends BaseLanguageActivity { + private static final String TAG = DirectorySelectActivity.class.getSimpleName(); + public static final int SELECT_DIR_REQUEST_CODE = 0x01; + private static final int ERROR_REQUEST_CODE = 0x02; + + private Uri initialUri; + private ActivityDirectorySelectBinding binding; + private DirectorySelectActivityViewModel viewModel; + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityDirectorySelectBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + viewModel = new ViewModelProvider(this).get(DirectorySelectActivityViewModel.class); + setupObservers(); + binding.selectDir.setOnClickListener(v -> openDirectoryChooser()); + AppExecutors.INSTANCE.getMainThread().execute(() -> viewModel.setInitialUri(getIntent())); + } + + private void setupObservers() { + viewModel.getMessage().observe(this, message -> binding.message.setText(message)); + viewModel.getPrevUri().observe(this, prevUri -> { + if (prevUri == null) { + binding.prevUri.setVisibility(View.GONE); + binding.message2.setVisibility(View.GONE); + return; + } + binding.prevUri.setText(prevUri); + binding.prevUri.setVisibility(View.VISIBLE); + binding.message2.setVisibility(View.VISIBLE); + }); + viewModel.getDirSuccess().observe(this, success -> binding.selectDir.setVisibility(success ? View.GONE : View.VISIBLE)); + viewModel.isLoading().observe(this, loading -> { + binding.message.setVisibility(loading ? View.GONE : View.VISIBLE); + binding.loadingIndicator.setVisibility(loading ? View.VISIBLE : View.GONE); + }); + } + + private void openDirectoryChooser() { + final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && initialUri != null) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri); + } + try { + startActivityForResult(intent, SELECT_DIR_REQUEST_CODE); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "openDirectoryChooser: ", e); + showErrorDialog(getString(R.string.no_directory_picker_activity)); + } catch (Exception e) { + Log.e(TAG, "openDirectoryChooser: ", e); + } + } + + @SuppressLint("StringFormatInvalid") + @Override + protected void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != SELECT_DIR_REQUEST_CODE) return; + if (resultCode != RESULT_OK) { + showErrorDialog(getString(R.string.select_a_folder)); + return; + } + if (data == null || data.getData() == null) { + showErrorDialog(getString(R.string.select_a_folder)); + return; + } + if (!"com.android.externalstorage.documents".equals(data.getData().getAuthority())) { + showErrorDialog(getString(R.string.dir_select_no_download_folder, data.getData().getAuthority())); + return; + } + AppExecutors.INSTANCE.getMainThread().execute(() -> { + try { + viewModel.setupSelectedDir(data); + final Intent intent = new Intent(this, MainActivity.class); + startActivity(intent); + finish(); + } catch (Exception e) { + // Should not come to this point. + // If it does, we have to show this error to the user so that they can report it. + try (final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw)) { + e.printStackTrace(pw); + showErrorDialog("Please report this error to the developers:\n\n" + sw.toString()); + } catch (IOException ioException) { + Log.e(TAG, "onActivityResult: ", ioException); + } + } + }, 500); + } + + private void showErrorDialog(@NonNull final String message) { + final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( + ERROR_REQUEST_CODE, + R.string.error, + message, + R.string.ok, + 0, + 0 + ); + dialogFragment.show(getSupportFragmentManager(), ConfirmDialogFragment.class.getSimpleName()); + } +} diff --git a/app/src/main/java/awais/instagrabber/activities/Login.kt b/app/src/main/java/awais/instagrabber/activities/Login.kt new file mode 100755 index 0000000..d240b7b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/activities/Login.kt @@ -0,0 +1,119 @@ +package awais.instagrabber.activities + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.webkit.* +import android.widget.Toast +import awais.instagrabber.R +import awais.instagrabber.databinding.ActivityLoginBinding +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.getCookie + +class Login : BaseLanguageActivity(), View.OnClickListener { + private var webViewUrl: String? = null + private var ready = false + private lateinit var loginBinding: ActivityLoginBinding + + private val webChromeClient = WebChromeClient() + private val webViewClient: WebViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + webViewUrl = url + } + + override fun onPageFinished(view: WebView, url: String) { + webViewUrl = url + val mainCookie = getCookie(url) + if (mainCookie.isNullOrBlank() || !mainCookie.contains("; ds_user_id=")) { + ready = true + return + } + if (mainCookie.contains("; ds_user_id=") && ready) { + returnCookieResult(mainCookie) + } + } + } + + private fun returnCookieResult(mainCookie: String?) { + val intent = Intent() + intent.putExtra("cookie", mainCookie) + setResult(Constants.LOGIN_RESULT_CODE, intent) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loginBinding = ActivityLoginBinding.inflate(LayoutInflater.from(applicationContext)) + setContentView(loginBinding.root) + initWebView() + loginBinding.cookies.setOnClickListener(this) + loginBinding.refresh.setOnClickListener(this) + } + + override fun onClick(v: View) { + if (v === loginBinding.refresh) { + loginBinding.webView.loadUrl("https://instagram.com/") + return + } + if (v === loginBinding.cookies) { + val mainCookie = getCookie(webViewUrl) + if (mainCookie.isNullOrBlank() || !mainCookie.contains("; ds_user_id=")) { + Toast.makeText(this, R.string.login_error_loading_cookies, Toast.LENGTH_SHORT).show() + return + } + returnCookieResult(mainCookie) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initWebView() { + loginBinding.webView.webChromeClient = webChromeClient + loginBinding.webView.webViewClient = webViewClient + val webSettings = loginBinding.webView.settings + webSettings.userAgentString = + "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.105 Mobile Safari/537.36" + webSettings.javaScriptEnabled = true + webSettings.domStorageEnabled = true + webSettings.setSupportZoom(true) + webSettings.builtInZoomControls = true + webSettings.displayZoomControls = false + webSettings.loadWithOverviewMode = true + webSettings.useWideViewPort = true + webSettings.allowFileAccessFromFileURLs = true + webSettings.allowUniversalAccessFromFileURLs = true + webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + } else { + val cookieSyncMngr = CookieSyncManager.createInstance(applicationContext) + cookieSyncMngr.startSync() + val cookieManager = CookieManager.getInstance() + cookieManager.removeAllCookie() + cookieManager.removeSessionCookie() + cookieSyncMngr.stopSync() + cookieSyncMngr.sync() + } + loginBinding.webView.loadUrl("https://instagram.com/") + } + + override fun onPause() { + loginBinding.webView.onPause() + super.onPause() + } + + override fun onResume() { + super.onResume() + loginBinding.webView.onResume() + } + + override fun onDestroy() { + loginBinding.webView.destroy() + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/activities/MainActivity.kt b/app/src/main/java/awais/instagrabber/activities/MainActivity.kt new file mode 100644 index 0000000..bd552eb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/activities/MainActivity.kt @@ -0,0 +1,715 @@ +package awais.instagrabber.activities + +import android.animation.LayoutTransition +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.* +import android.provider.DocumentsContract.EXTRA_INITIAL_URI +import android.text.Editable +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.WindowManager +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.app.NotificationManagerCompat +import androidx.core.provider.FontRequest +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.emoji.text.EmojiCompat +import androidx.emoji.text.EmojiCompat.InitCallback +import androidx.emoji.text.FontRequestEmojiCompatConfig +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph +import androidx.navigation.NavGraphNavigator +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.* +import awais.instagrabber.BuildConfig +import awais.instagrabber.R +import awais.instagrabber.customviews.emoji.EmojiVariantManager +import awais.instagrabber.customviews.helpers.RootViewDeferringInsetsCallback +import awais.instagrabber.customviews.helpers.TextWatcherAdapter +import awais.instagrabber.databinding.ActivityMainBinding +import awais.instagrabber.fragments.main.FeedFragment +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.models.IntentModel +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Tab +import awais.instagrabber.models.enums.IntentModelType +import awais.instagrabber.services.ActivityCheckerService +import awais.instagrabber.services.DMSyncAlarmReceiver +import awais.instagrabber.utils.* +import awais.instagrabber.utils.AppExecutors.tasksThread +import awais.instagrabber.utils.DownloadUtils.ReselectDocumentTreeException +import awais.instagrabber.utils.TextUtils.isEmpty +import awais.instagrabber.utils.emoji.EmojiParser +import awais.instagrabber.viewmodels.AppStateViewModel +import awais.instagrabber.viewmodels.DirectInboxViewModel +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior +import com.google.android.material.appbar.CollapsingToolbarLayout +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.textfield.TextInputLayout +import com.google.common.collect.ImmutableList +import java.util.* + + +class MainActivity : BaseLanguageActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var navController: NavController + private lateinit var appBarConfiguration: AppBarConfiguration + + private var searchMenuItem: MenuItem? = null + private var startNavRootId: Int = 0 + + private var lastSelectedNavMenuId = 0 + private var isActivityCheckerServiceBound = false + private var isLoggedIn = false + private var deviceUuid: String? = null + private var csrfToken: String? = null + private var userId: Long = 0 + private var toolbarOwner: Fragment? = null + + private lateinit var toolbar: Toolbar + + var currentTabs: List = emptyList() + private set + private var showBottomViewDestinations: List = emptyList() + + private val serviceConnection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + // final ActivityCheckerService.LocalBinder binder = (ActivityCheckerService.LocalBinder) service; + // final ActivityCheckerService activityCheckerService = binder.getService(); + isActivityCheckerServiceBound = true + } + + override fun onServiceDisconnected(name: ComponentName) { + isActivityCheckerServiceBound = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + try { + DownloadUtils.init( + this, + Utils.settingsHelper.getString(PreferenceKeys.PREF_BARINSTA_DIR_URI) + ) + } catch (e: ReselectDocumentTreeException) { + super.onCreate(savedInstanceState) + val intent = Intent(this, DirectorySelectActivity::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.putExtra(EXTRA_INITIAL_URI, e.initialUri) + } + startActivity(intent) + finish() + return + } + super.onCreate(savedInstanceState) + instance = this + binding = ActivityMainBinding.inflate(layoutInflater) + toolbar = binding.toolbar + setupCookie() + if (Utils.settingsHelper.getBoolean(PreferenceKeys.FLAG_SECURE)) { + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + setupInsetsCallback() + createNotificationChannels() + val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_nav_host) as NavHostFragment + navController = navHostFragment.navController + if (savedInstanceState == null) { + setupNavigation(true) + } + if (!BuildConfig.isPre) { + val checkUpdates = Utils.settingsHelper.getBoolean(PreferenceKeys.CHECK_UPDATES) + if (checkUpdates) FlavorTown.updateCheck(this) + } + FlavorTown.changelogCheck(this) + ViewModelProvider(this).get(AppStateViewModel::class.java) // Just initiate the App state here + handleIntent(intent) + if (isLoggedIn && Utils.settingsHelper.getBoolean(PreferenceKeys.CHECK_ACTIVITY)) { + bindActivityCheckerService() + } + // Initialise the internal map + tasksThread.execute { + EmojiParser.getInstance(this) + EmojiVariantManager.getInstance() + } + initEmojiCompat() + // initDmService(); + initDmUnreadCount() + initSearchInput() + } + + private fun setupInsetsCallback() { + val deferringInsetsCallback = RootViewDeferringInsetsCallback( + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime() + ) + ViewCompat.setWindowInsetsAnimationCallback(binding.root, deferringInsetsCallback) + ViewCompat.setOnApplyWindowInsetsListener(binding.root, deferringInsetsCallback) + WindowCompat.setDecorFitsSystemWindows(window, false) + } + + private fun setupCookie() { + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + userId = 0 + csrfToken = null + if (cookie.isNotBlank()) { + userId = getUserIdFromCookie(cookie) + csrfToken = getCsrfTokenFromCookie(cookie) + } + if (cookie.isBlank() || userId == 0L || csrfToken.isNullOrBlank()) { + isLoggedIn = false + return + } + deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + if (isEmpty(deviceUuid)) { + Utils.settingsHelper.putString(Constants.DEVICE_UUID, UUID.randomUUID().toString()) + } + setupCookies(cookie) + isLoggedIn = true + } + + @Suppress("unused") + private fun initDmService() { + if (!isLoggedIn) return + val enabled = Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH) + if (!enabled) return + DMSyncAlarmReceiver.setAlarm(this) + } + + private fun initDmUnreadCount() { + if (!isLoggedIn) return + val directInboxViewModel = ViewModelProvider(this).get(DirectInboxViewModel::class.java) + directInboxViewModel.unseenCount.observe(this, { unseenCountResource: Resource? -> + if (unseenCountResource == null) return@observe + val unseenCount = unseenCountResource.data + setNavBarDMUnreadCountBadge(unseenCount ?: 0) + }) + } + + private fun initSearchInput() { + binding.searchInputLayout.setEndIconOnClickListener { + val editText = binding.searchInputLayout.editText ?: return@setEndIconOnClickListener + editText.setText("") + } + binding.searchInputLayout.addOnEditTextAttachedListener { textInputLayout: TextInputLayout -> + textInputLayout.isEndIconVisible = false + val editText = textInputLayout.editText ?: return@addOnEditTextAttachedListener + editText.addTextChangedListener(object : TextWatcherAdapter() { + override fun afterTextChanged(s: Editable) { + binding.searchInputLayout.isEndIconVisible = !isEmpty(s) + } + }) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main_menu, menu) + searchMenuItem = menu.findItem(R.id.search) + val currentDestination = navController.currentDestination + if (currentDestination != null) { + val backStack = navController.backQueue + setupMenu(backStack.size, currentDestination.id) + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.search) { + try { + navController.navigate(getSearchDeepLink()) + return true + } catch (e: Exception) { + Log.e(TAG, "onOptionsItemSelected: ", e) + } + return false + } + return super.onOptionsItemSelected(item) + } + + override fun onSaveInstanceState(outState: Bundle) { + // outState.putString(FIRST_FRAGMENT_GRAPH_INDEX_KEY, firstFragmentGraphIndex.toString()) + outState.putString(LAST_SELECT_NAV_MENU_ID, binding.bottomNavView.selectedItemId.toString()) + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val lastSelected = savedInstanceState[LAST_SELECT_NAV_MENU_ID] as String? + if (lastSelected != null) { + try { + lastSelectedNavMenuId = lastSelected.toInt() + } catch (ignored: NumberFormatException) { + } + } + setupNavigation(false) + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp(appBarConfiguration) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onDestroy() { + try { + super.onDestroy() + } catch (e: Exception) { + Log.e(TAG, "onDestroy: ", e) + } + unbindActivityCheckerService() + // try { + // RetrofitFactory.getInstance().destroy() + // } catch (e: Exception) { + // Log.e(TAG, "onDestroy: ", e) + // } + DownloadUtils.destroy() + instance = null + } + + // override fun onBackPressed() { + // Log.d(TAG, "onBackPressed: ") + // navController.navigateUp() + // val backStack = navController.backQueue + // val currentNavControllerBackStack = backStack.size + // if (isTaskRoot && isBackStackEmpty && currentNavControllerBackStack == 2) { + // finishAfterTransition() + // return + // } + // if (!isFinishing) { + // try { + // super.onBackPressed() + // } catch (e: Exception) { + // Log.e(TAG, "onBackPressed: ", e) + // finish() + // } + // } + // } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = NotificationManagerCompat.from(applicationContext) + notificationManager.createNotificationChannel( + NotificationChannel( + Constants.DOWNLOAD_CHANNEL_ID, + Constants.DOWNLOAD_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + notificationManager.createNotificationChannel( + NotificationChannel( + Constants.ACTIVITY_CHANNEL_ID, + Constants.ACTIVITY_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + notificationManager.createNotificationChannel( + NotificationChannel( + Constants.DM_UNREAD_CHANNEL_ID, + Constants.DM_UNREAD_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + val silentNotificationChannel = NotificationChannel( + Constants.SILENT_NOTIFICATIONS_CHANNEL_ID, + Constants.SILENT_NOTIFICATIONS_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + silentNotificationChannel.setSound(null, null) + notificationManager.createNotificationChannel(silentNotificationChannel) + } + + private fun setupNavigation(setDefaultTabFromSettings: Boolean) { + currentTabs = if (isLoggedIn) setupMainBottomNav() else setupAnonBottomNav() + showBottomViewDestinations = currentTabs.asSequence().map { + it.startDestinationFragmentId + }.toMutableList().apply { + add(R.id.postViewFragment) + add(R.id.favorites_non_top) + add(R.id.notifications_viewer_non_top) + add(R.id.profile_non_top) + } + if (setDefaultTabFromSettings) { + setSelectedTab(currentTabs) + } else { + binding.bottomNavView.selectedItemId = lastSelectedNavMenuId + } + val navigatorProvider = navController.navigatorProvider + val navigator = navigatorProvider.getNavigator("navigation") + val rootNavGraph = NavGraph(navigator) + val navInflater = navController.navInflater + val topLevelDestinations = currentTabs.map { navInflater.inflate(it.navigationResId) } + rootNavGraph.id = R.id.root_nav_graph + rootNavGraph.label = "root_nav_graph" + rootNavGraph.addDestinations(topLevelDestinations) + rootNavGraph.setStartDestination(if (startNavRootId != 0) startNavRootId else R.id.profile_nav_graph) + navController.graph = rootNavGraph + binding.bottomNavView.setupWithNavController(navController) + appBarConfiguration = AppBarConfiguration(currentTabs.map { it.startDestinationFragmentId }.toSet()) + setupActionBarWithNavController(navController, appBarConfiguration) + navController.addOnDestinationChangedListener { _: NavController?, destination: NavDestination, arguments: Bundle? -> + if (destination.id == R.id.directMessagesThreadFragment && arguments != null) { + // Set the thread title earlier for better ux + val title = arguments.getString("title") + if (!title.isNullOrBlank()) { + supportActionBar?.title = title + } + } + if (destination.id == R.id.profileFragment && arguments != null) { + // Set the title to username + val username = arguments.getString("username") + if (!username.isNullOrBlank()) { + supportActionBar?.title = username.substringAfter("@") + } + } + // below is a hack to check if we are at the end of the current stack, to setup the search view + binding.appBarLayout.setExpanded(true, true) + val destinationId = destination.id + val backStack = navController.backQueue + setupMenu(backStack.size, destinationId) + val contains = showBottomViewDestinations.contains(destinationId) + binding.root.post { + binding.bottomNavView.visibility = if (contains) View.VISIBLE else View.GONE + // if (contains) { + // behavior?.slideUp(binding.bottomNavView) + // } + } + // explicitly hide keyboard when we navigate + val view = currentFocus + Utils.hideKeyboard(view) + } + setupReselection() + } + + private fun setupReselection() { + binding.bottomNavView.setOnItemReselectedListener { + val navHostFragment = (supportFragmentManager.primaryNavigationFragment ?: return@setOnItemReselectedListener) as NavHostFragment + val currentFragment = navHostFragment.childFragmentManager.fragments.firstOrNull() ?: return@setOnItemReselectedListener + if (currentFragment is FeedFragment) { + currentFragment.scrollToTop() + return@setOnItemReselectedListener + } + val currentDestination = navController.currentDestination ?: return@setOnItemReselectedListener + val currentTabStartDestId = (navController.getBackStackEntry(it.itemId).destination as NavGraph).startDestinationId + if (currentDestination.id == currentTabStartDestId) return@setOnItemReselectedListener + navController.popBackStack(currentTabStartDestId, false) + } + } + + private fun setSelectedTab(tabs: List) { + val defaultTabResNameString = Utils.settingsHelper.getString(Constants.DEFAULT_TAB) + try { + var navId = 0 + if (defaultTabResNameString.isNotBlank()) { + navId = resources.getIdentifier(defaultTabResNameString, "id", packageName) + } + val startFragmentNavResId = if (navId <= 0) R.id.profile_nav_graph else navId + val tab = tabs.firstOrNull { it.navigationRootId == startFragmentNavResId } + // if (index < 0 || index >= tabs.size) index = 0 + val firstTab = tab ?: tabs[0] + startNavRootId = firstTab.navigationRootId + binding.bottomNavView.selectedItemId = firstTab.navigationRootId + } catch (e: Exception) { + Log.e(TAG, "Error parsing id", e) + } + } + + private fun setupAnonBottomNav(): List { + val selectedItemId = binding.bottomNavView.selectedItemId + val anonNavTabs = getAnonNavTabs(this) + val menu = binding.bottomNavView.menu + menu.clear() + for (tab in anonNavTabs) { + menu.add(0, tab.navigationRootId, 0, tab.title).setIcon(tab.iconResId) + } + if (selectedItemId != R.id.profile_nav_graph && selectedItemId != R.id.more_nav_graph && selectedItemId != R.id.favorites_nav_graph) { + binding.bottomNavView.selectedItemId = R.id.profile_nav_graph + } + return anonNavTabs + } + + private fun setupMainBottomNav(): List { + val menu = binding.bottomNavView.menu + menu.clear() + val navTabList = getLoggedInNavTabs(this).first + for (tab in navTabList) { + menu.add(0, tab.navigationRootId, 0, tab.title).setIcon(tab.iconResId) + } + return navTabList + } + + private fun setupMenu(backStackSize: Int, destinationId: Int) { + val searchMenuItem = searchMenuItem ?: return + if (backStackSize >= 2 && SEARCH_VISIBLE_DESTINATIONS.contains(destinationId)) { + searchMenuItem.isVisible = true + return + } + searchMenuItem.isVisible = false + } + + private fun setScrollingBehaviour() { + val layoutParams = binding.mainNavHost.layoutParams as CoordinatorLayout.LayoutParams + layoutParams.behavior = ScrollingViewBehavior() + binding.mainNavHost.requestLayout() + } + + private fun removeScrollingBehaviour() { + val layoutParams = binding.mainNavHost.layoutParams as CoordinatorLayout.LayoutParams + layoutParams.behavior = null + binding.mainNavHost.requestLayout() + } + + private fun handleIntent(intent: Intent?) { + if (intent == null) return + val action = intent.action + val type = intent.type + // Log.d(TAG, action + " " + type); + if (Intent.ACTION_MAIN == action) return + if (Constants.ACTION_SHOW_ACTIVITY == action) { + showActivityView() + return + } + if (Constants.ACTION_SHOW_DM_THREAD == action) { + showThread(intent) + return + } + if (Intent.ACTION_SEND == action && type != null) { + if (type == "text/plain") { + handleUrl(intent.getStringExtra(Intent.EXTRA_TEXT)) + } + return + } + if (Intent.ACTION_VIEW == action) { + val data = intent.data ?: return + handleUrl(data.toString()) + } + } + + private fun showThread(intent: Intent) { + val threadId = intent.getStringExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_ID) + val threadTitle = intent.getStringExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_TITLE) + navigateToThread(threadId, threadTitle) + } + + fun navigateToThread(threadId: String?, threadTitle: String?) { + if (threadId == null || threadTitle == null) return + try { + navController.navigate(getDirectThreadDeepLink(threadId, threadTitle)) + } catch (e: Exception) { + Log.e(TAG, "navigateToThread: ", e) + } + } + + private fun handleUrl(url: String?) { + if (url == null) return + // Log.d(TAG, url); + val intentModel = IntentUtils.parseUrl(url) ?: return + showView(intentModel) + } + + private fun showView(intentModel: IntentModel) { + when (intentModel.type) { + IntentModelType.USERNAME -> showProfileView(intentModel) + IntentModelType.POST -> showPostView(intentModel) + IntentModelType.LOCATION -> showLocationView(intentModel) + IntentModelType.HASHTAG -> showHashtagView(intentModel) + IntentModelType.UNKNOWN -> Log.w(TAG, "Unknown model type received!") + // else -> Log.w(TAG, "Unknown model type received!") + } + } + + private fun showProfileView(intentModel: IntentModel) { + try { + val username = intentModel.text + navController.navigate(getProfileDeepLink(username)) + } catch (e: Exception) { + Log.e(TAG, "showProfileView: ", e) + } + } + + private fun showPostView(intentModel: IntentModel) { + val shortCode = intentModel.text + // Log.d(TAG, "shortCode: " + shortCode); + try { + navController.navigate(getPostDeepLink(shortCode)) + } catch (e: Exception) { + Log.e(TAG, "showPostView: ", e) + } + } + + private fun showLocationView(intentModel: IntentModel) { + val locationId = intentModel.text + // Log.d(TAG, "locationId: " + locationId); + try { + navController.navigate(getLocationDeepLink(locationId)) + } catch (e: Exception) { + Log.e(TAG, "showLocationView: ", e) + } + } + + private fun showHashtagView(intentModel: IntentModel) { + val hashtag = intentModel.text + // Log.d(TAG, "hashtag: " + hashtag); + try { + navController.navigate(getHashtagDeepLink(hashtag)) + } catch (e: Exception) { + Log.e(TAG, "showHashtagView: ", e) + } + } + + private fun showActivityView() { + try { + navController.navigate(getNotificationsDeepLink("notif")) + } catch (e: Exception) { + Log.e(TAG, "showActivityView: ", e) + } + } + + private fun bindActivityCheckerService() { + bindService(Intent(this, ActivityCheckerService::class.java), serviceConnection, BIND_AUTO_CREATE) + isActivityCheckerServiceBound = true + } + + private fun unbindActivityCheckerService() { + if (!isActivityCheckerServiceBound) return + unbindService(serviceConnection) + isActivityCheckerServiceBound = false + } + + val bottomNavView: BottomNavigationView + get() = binding.bottomNavView + + // fun setCollapsingView(view: View) { + // try { + // binding.collapsingToolbarLayout.addView(view, 0) + // } catch (e: Exception) { + // Log.e(TAG, "setCollapsingView: ", e) + // } + // } + // + // fun removeCollapsingView(view: View) { + // try { + // binding.collapsingToolbarLayout.removeView(view) + // } catch (e: Exception) { + // Log.e(TAG, "removeCollapsingView: ", e) + // } + // } + + val collapsingToolbarView: CollapsingToolbarLayout + get() = binding.collapsingToolbarLayout + val appbarLayout: AppBarLayout + get() = binding.appBarLayout + + fun removeLayoutTransition() { + binding.root.layoutTransition = null + } + + fun setLayoutTransition() { + binding.root.layoutTransition = LayoutTransition() + } + + private fun initEmojiCompat() { + // Use a downloadable font for EmojiCompat + val fontRequest = FontRequest( + "com.google.android.gms.fonts", + "com.google.android.gms", + "Noto Color Emoji Compat", + R.array.com_google_android_gms_fonts_certs + ) + val config: EmojiCompat.Config = FontRequestEmojiCompatConfig(applicationContext, fontRequest) + config.setReplaceAll(true) // .setUseEmojiAsDefaultStyle(true) + .registerInitCallback(object : InitCallback() { + override fun onInitialized() { + Log.i(TAG, "EmojiCompat initialized") + } + + override fun onFailed(throwable: Throwable?) { + Log.e(TAG, "EmojiCompat initialization failed", throwable) + } + }) + EmojiCompat.init(config) + } + + val rootView: View + get() = binding.root + + private val toolbarLock = Any() + + fun getToolbar() = synchronized(toolbarLock) { this.toolbar } + + fun setToolbar(toolbar: Toolbar, owner: Fragment) = synchronized(toolbarLock) { + supportActionBar?.subtitle = null + toolbarOwner = owner + binding.appBarLayout.visibility = View.GONE + removeScrollingBehaviour() + setSupportActionBar(toolbar) + this.toolbar = toolbar + NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration) + } + + fun resetToolbar(owner: Fragment) = synchronized(toolbarLock) { + if (owner != toolbarOwner) return + this.toolbar = binding.toolbar + setSupportActionBar(binding.toolbar) + binding.appBarLayout.visibility = View.VISIBLE + setScrollingBehaviour() + setupActionBarWithNavController(navController, appBarConfiguration) + toolbarOwner = null + } + + private fun setNavBarDMUnreadCountBadge(unseenCount: Int) { + val badge = binding.bottomNavView.getOrCreateBadge(R.id.direct_messages_nav_graph) + if (unseenCount == 0) { + badge.isVisible = false + badge.clearNumber() + return + } + if (badge.verticalOffset != 10) { + badge.verticalOffset = 10 + } + badge.number = unseenCount + badge.isVisible = true + } + + fun showSearchView(): TextInputLayout { + binding.searchInputLayout.visibility = View.VISIBLE + return binding.searchInputLayout + } + + fun hideSearchView() { + binding.searchInputLayout.visibility = View.GONE + } + + companion object { + private const val TAG = "MainActivity" + private const val LAST_SELECT_NAV_MENU_ID = "lastSelectedNavMenuId" + + private val SEARCH_VISIBLE_DESTINATIONS: List = ImmutableList.of( + R.id.feedFragment, + R.id.profileFragment, + R.id.directMessagesInboxFragment, + R.id.discoverFragment, + R.id.favoritesFragment, + R.id.hashTagFragment, + R.id.locationFragment + ) + + @JvmStatic + var instance: MainActivity? = null + private set + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/AccountSwitcherAdapter.java b/app/src/main/java/awais/instagrabber/adapters/AccountSwitcherAdapter.java new file mode 100644 index 0000000..10cd73d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/AccountSwitcherAdapter.java @@ -0,0 +1,112 @@ +package awais.instagrabber.adapters; + +import android.annotation.SuppressLint; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.PrefAccountSwitcherBinding; +import awais.instagrabber.db.entities.Account; +import awais.instagrabber.utils.Constants; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class AccountSwitcherAdapter extends ListAdapter { + private static final String TAG = "AccountSwitcherAdapter"; + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Account oldItem, @NonNull final Account newItem) { + return oldItem.getUid().equals(newItem.getUid()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Account oldItem, @NonNull final Account newItem) { + return oldItem.getUid().equals(newItem.getUid()); + } + }; + + private final OnAccountClickListener clickListener; + private final OnAccountLongClickListener longClickListener; + + public AccountSwitcherAdapter(final OnAccountClickListener clickListener, + final OnAccountLongClickListener longClickListener) { + super(DIFF_CALLBACK); + this.clickListener = clickListener; + this.longClickListener = longClickListener; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final PrefAccountSwitcherBinding binding = PrefAccountSwitcherBinding.inflate(layoutInflater, parent, false); + return new ViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { + final Account model = getItem(position); + if (model == null) return; + final String cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isCurrent = model.getCookie().equals(cookie); + holder.bind(model, isCurrent, clickListener, longClickListener); + } + + public interface OnAccountClickListener { + void onAccountClick(final Account model, final boolean isCurrent); + } + + public interface OnAccountLongClickListener { + boolean onAccountLongClick(final Account model, final boolean isCurrent); + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + private final PrefAccountSwitcherBinding binding; + + public ViewHolder(final PrefAccountSwitcherBinding binding) { + super(binding.getRoot()); + this.binding = binding; + binding.arrowDown.setImageResource(R.drawable.ic_check_24); + } + + @SuppressLint("SetTextI18n") + public void bind(final Account model, + final boolean isCurrent, + final OnAccountClickListener clickListener, + final OnAccountLongClickListener longClickListener) { + // Log.d(TAG, model.getFullName()); + itemView.setOnClickListener(v -> { + if (clickListener == null) return; + clickListener.onAccountClick(model, isCurrent); + }); + itemView.setOnLongClickListener(v -> { + if (longClickListener == null) return false; + return longClickListener.onAccountLongClick(model, isCurrent); + }); + binding.profilePic.setImageURI(model.getProfilePic()); + binding.username.setText("@" + model.getUsername()); + binding.fullName.setTypeface(null); + final String fullName = model.getFullName(); + if (TextUtils.isEmpty(fullName)) { + binding.fullName.setVisibility(View.GONE); + } else { + binding.fullName.setVisibility(View.VISIBLE); + binding.fullName.setText(fullName); + } + if (!isCurrent) { + binding.arrowDown.setVisibility(View.GONE); + return; + } + binding.fullName.setTypeface(binding.fullName.getTypeface(), Typeface.BOLD); + binding.arrowDown.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/CommentsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/CommentsAdapter.java new file mode 100755 index 0000000..58f7d1f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/CommentsAdapter.java @@ -0,0 +1,77 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.Objects; + +import awais.instagrabber.adapters.viewholder.CommentViewHolder; +import awais.instagrabber.databinding.ItemCommentBinding; +import awais.instagrabber.models.Comment; + +public final class CommentsAdapter extends ListAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Comment oldItem, @NonNull final Comment newItem) { + return Objects.equals(oldItem.getPk(), newItem.getPk()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Comment oldItem, @NonNull final Comment newItem) { + return Objects.equals(oldItem, newItem); + } + }; + + private final boolean showingReplies; + private final CommentCallback commentCallback; + private final long currentUserId; + + public CommentsAdapter(final long currentUserId, + final boolean showingReplies, + final CommentCallback commentCallback) { + super(DIFF_CALLBACK); + this.showingReplies = showingReplies; + this.currentUserId = currentUserId; + this.commentCallback = commentCallback; + } + + @NonNull + @Override + public CommentViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemCommentBinding binding = ItemCommentBinding.inflate(layoutInflater, parent, false); + return new CommentViewHolder(binding, currentUserId, commentCallback); + } + + @Override + public void onBindViewHolder(@NonNull final CommentViewHolder holder, final int position) { + final Comment comment = getItem(position); + holder.bind(comment, showingReplies && position == 0, showingReplies && position != 0); + } + + public interface CommentCallback { + void onClick(final Comment comment); + + void onHashtagClick(final String hashtag); + + void onMentionClick(final String mention); + + void onURLClick(final String url); + + void onEmailClick(final String emailAddress); + + void onLikeClick(final Comment comment, boolean liked, final boolean isReply); + + void onRepliesClick(final Comment comment); + + void onViewLikes(Comment comment); + + void onTranslate(Comment comment); + + void onDelete(Comment comment, boolean isReply); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java new file mode 100644 index 0000000..dd74491 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java @@ -0,0 +1,424 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemActionLogViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemAnimatedMediaViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemDefaultViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemLikeViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemLinkViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemMediaShareViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemMediaViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemPlaceholderViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemProfileViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemRavenMediaViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemReelShareViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemStoryShareViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemTextViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemVideoCallEventViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemVoiceMediaViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemXmaViewHolder; +import awais.instagrabber.customviews.emoji.Emoji; +import awais.instagrabber.databinding.LayoutDmActionLogBinding; +import awais.instagrabber.databinding.LayoutDmAnimatedMediaBinding; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmHeaderBinding; +import awais.instagrabber.databinding.LayoutDmLikeBinding; +import awais.instagrabber.databinding.LayoutDmLinkBinding; +import awais.instagrabber.databinding.LayoutDmMediaBinding; +import awais.instagrabber.databinding.LayoutDmMediaShareBinding; +import awais.instagrabber.databinding.LayoutDmProfileBinding; +import awais.instagrabber.databinding.LayoutDmRavenMediaBinding; +import awais.instagrabber.databinding.LayoutDmReelShareBinding; +import awais.instagrabber.databinding.LayoutDmStoryShareBinding; +import awais.instagrabber.databinding.LayoutDmTextBinding; +import awais.instagrabber.databinding.LayoutDmVoiceMediaBinding; +import awais.instagrabber.models.enums.DirectItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; + +public final class DirectItemsAdapter extends RecyclerView.Adapter { + private static final String TAG = DirectItemsAdapter.class.getSimpleName(); + + private List items; + private DirectThread thread; + private DirectItemViewHolder selectedViewHolder; + + private final User currentUser; + private final DirectItemCallback callback; + private final AsyncListDiffer differ; + private final DirectItemInternalLongClickListener longClickListener; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final DirectItemOrHeader oldItem, @NonNull final DirectItemOrHeader newItem) { + final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); + final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); + boolean areSameType = bothHeaders || bothItems; + if (!areSameType) return false; + if (bothHeaders) { + return oldItem.date.equals(newItem.date); + } + if (oldItem.item != null && newItem.item != null) { + String oldClientContext = oldItem.item.getClientContext(); + if (oldClientContext == null) { + oldClientContext = oldItem.item.getItemId(); + } + String newClientContext = newItem.item.getClientContext(); + if (newClientContext == null) { + newClientContext = newItem.item.getItemId(); + } + return oldClientContext.equals(newClientContext); + } + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull final DirectItemOrHeader oldItem, @NonNull final DirectItemOrHeader newItem) { + final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); + final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); + boolean areSameType = bothHeaders || bothItems; + if (!areSameType) return false; + if (bothHeaders) { + return oldItem.date.equals(newItem.date); + } + final boolean timestampEqual = oldItem.item.getTimestamp() == newItem.item.getTimestamp(); + final boolean bothPending = oldItem.item.isPending() == newItem.item.isPending(); + final boolean reactionSame = Objects.equals(oldItem.item.getReactions(), newItem.item.getReactions()); + return timestampEqual && bothPending && reactionSame; + } + }; + + public DirectItemsAdapter(@NonNull final User currentUser, + @NonNull final DirectThread thread, + @NonNull final DirectItemCallback callback, + @NonNull final DirectItemLongClickListener itemLongClickListener) { + this.currentUser = currentUser; + this.thread = thread; + this.callback = callback; + differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(diffCallback).build()); + longClickListener = (position, viewHolder) -> { + if (selectedViewHolder != null) { + selectedViewHolder.setSelected(false); + } + selectedViewHolder = viewHolder; + viewHolder.setSelected(true); + itemLongClickListener.onLongClick(position); + }; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + if (type == -1) { + // header + return new HeaderViewHolder(LayoutDmHeaderBinding.inflate(layoutInflater, parent, false)); + } + final LayoutDmBaseBinding baseBinding = LayoutDmBaseBinding.inflate(layoutInflater, parent, false); + final DirectItemType directItemType = DirectItemType.Companion.getTypeFromId(type); + final DirectItemViewHolder itemViewHolder = getItemViewHolder(layoutInflater, baseBinding, directItemType); + itemViewHolder.setLongClickListener(longClickListener); + return itemViewHolder; + } + + @NonNull + private DirectItemViewHolder getItemViewHolder(final LayoutInflater layoutInflater, + final LayoutDmBaseBinding baseBinding, + @NonNull final DirectItemType directItemType) { + switch (directItemType) { + case TEXT: { + final LayoutDmTextBinding binding = LayoutDmTextBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemTextViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case LIKE: { + final LayoutDmLikeBinding binding = LayoutDmLikeBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemLikeViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case LINK: { + final LayoutDmLinkBinding binding = LayoutDmLinkBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemLinkViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case ACTION_LOG: { + final LayoutDmActionLogBinding binding = LayoutDmActionLogBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemActionLogViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case VIDEO_CALL_EVENT: { + final LayoutDmActionLogBinding binding = LayoutDmActionLogBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemVideoCallEventViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case PLACEHOLDER: { + final LayoutDmStoryShareBinding binding = LayoutDmStoryShareBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemPlaceholderViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case ANIMATED_MEDIA: { + final LayoutDmAnimatedMediaBinding binding = LayoutDmAnimatedMediaBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemAnimatedMediaViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case VOICE_MEDIA: { + final LayoutDmVoiceMediaBinding binding = LayoutDmVoiceMediaBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemVoiceMediaViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case LOCATION: + case PROFILE: { + final LayoutDmProfileBinding binding = LayoutDmProfileBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemProfileViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case MEDIA: { + final LayoutDmMediaBinding binding = LayoutDmMediaBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemMediaViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case CLIP: + case FELIX_SHARE: + case MEDIA_SHARE: { + final LayoutDmMediaShareBinding binding = LayoutDmMediaShareBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemMediaShareViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case STORY_SHARE: { + final LayoutDmStoryShareBinding binding = LayoutDmStoryShareBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemStoryShareViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case REEL_SHARE: { + final LayoutDmReelShareBinding binding = LayoutDmReelShareBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemReelShareViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case RAVEN_MEDIA: { + final LayoutDmRavenMediaBinding binding = LayoutDmRavenMediaBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemRavenMediaViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case XMA: { + final LayoutDmAnimatedMediaBinding binding = LayoutDmAnimatedMediaBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemXmaViewHolder(baseBinding, binding, currentUser, thread, callback); + } + case UNKNOWN: + default: { + final LayoutDmTextBinding binding = LayoutDmTextBinding.inflate(layoutInflater, baseBinding.message, false); + return new DirectItemDefaultViewHolder(baseBinding, binding, currentUser, thread, callback); + } + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + final DirectItemOrHeader itemOrHeader = getItem(position); + if (itemOrHeader.isHeader()) { + ((HeaderViewHolder) holder).bind(itemOrHeader.date); + return; + } + if (thread == null) return; + ((DirectItemViewHolder) holder).bind(position, itemOrHeader.item); + } + + protected DirectItemOrHeader getItem(int position) { + return differ.getCurrentList().get(position); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public int getItemViewType(final int position) { + final DirectItemOrHeader itemOrHeader = getItem(position); + if (itemOrHeader.isHeader()) { + return -1; + } + final DirectItemType itemType = itemOrHeader.item.getItemType(); + if (itemType == null) { + return 0; + } + return itemType.getId(); + } + + @Override + public long getItemId(final int position) { + final DirectItemOrHeader itemOrHeader = getItem(position); + if (itemOrHeader.isHeader()) { + return itemOrHeader.date.hashCode(); + } + if (itemOrHeader.item.getClientContext() == null) { + return itemOrHeader.item.getItemId().hashCode(); + } + return itemOrHeader.item.getClientContext().hashCode(); + } + + public void setThread(final DirectThread thread) { + if (thread == null) return; + this.thread = thread; + // notifyDataSetChanged(); + } + + public void submitList(@Nullable final List list) { + if (list == null) { + differ.submitList(null); + return; + } + differ.submitList(sectionAndSort(list)); + this.items = list; + } + + public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { + if (list == null) { + differ.submitList(null, commitCallback); + return; + } + differ.submitList(sectionAndSort(list), commitCallback); + this.items = list; + } + + private List sectionAndSort(final List list) { + final List itemOrHeaders = new ArrayList<>(); + LocalDate prevSectionDate = null; + for (int i = 0; i < list.size(); i++) { + final DirectItem item = list.get(i); + if (item == null || item.getDate() == null) continue; + final DirectItemOrHeader prev = itemOrHeaders.isEmpty() ? null : itemOrHeaders.get(itemOrHeaders.size() - 1); + if (prev != null + && prev.item != null + && prev.item.getDate() != null + && prev.item.getDate().toLocalDate().isEqual(item.getDate().toLocalDate())) { + // just add item + final DirectItemOrHeader itemOrHeader = new DirectItemOrHeader(); + itemOrHeader.item = item; + itemOrHeaders.add(itemOrHeader); + if (i == list.size() - 1) { + // add header + final DirectItemOrHeader itemOrHeader2 = new DirectItemOrHeader(); + itemOrHeader2.date = prevSectionDate; + itemOrHeaders.add(itemOrHeader2); + } + continue; + } + if (prevSectionDate != null) { + // add header + final DirectItemOrHeader itemOrHeader = new DirectItemOrHeader(); + itemOrHeader.date = prevSectionDate; + itemOrHeaders.add(itemOrHeader); + } + // Add item + final DirectItemOrHeader itemOrHeader = new DirectItemOrHeader(); + itemOrHeader.item = item; + itemOrHeaders.add(itemOrHeader); + prevSectionDate = item.getDate().toLocalDate(); + } + return itemOrHeaders; + } + + public List getList() { + return differ.getCurrentList(); + } + + public List getItems() { + return items; + } + + @Override + public void onViewRecycled(@NonNull final RecyclerView.ViewHolder holder) { + if (holder instanceof DirectItemViewHolder) { + ((DirectItemViewHolder) holder).cleanup(); + } + } + + @Override + public void onViewDetachedFromWindow(@NonNull final RecyclerView.ViewHolder holder) { + if (holder instanceof DirectItemViewHolder) { + ((DirectItemViewHolder) holder).cleanup(); + } + } + + public DirectThread getThread() { + return thread; + } + + public static class DirectItemOrHeader { + LocalDate date; + public DirectItem item; + + public boolean isHeader() { + return date != null; + } + + @NonNull + @Override + public String toString() { + return "DirectItemOrHeader{" + + "date=" + date + + ", item=" + (item != null ? item.getItemType() : null) + + '}'; + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + private final LayoutDmHeaderBinding binding; + + public HeaderViewHolder(@NonNull final LayoutDmHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final LocalDate date) { + if (date == null) { + binding.header.setText(""); + return; + } + final DateTimeFormatter dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT); + binding.header.setText(dateFormatter.format(date)); + } + } + + public interface DirectItemCallback { + void onHashtagClick(String hashtag); + + void onMentionClick(String mention); + + void onLocationClick(long locationId); + + void onURLClick(String url); + + void onEmailClick(String email); + + void onMediaClick(Media media, int index); + + void onStoryClick(DirectItemStoryShare storyShare); + + void onReaction(DirectItem item, Emoji emoji); + + void onReactionClick(DirectItem item, int position); + + void onOptionSelect(DirectItem item, @IdRes int itemId, final Function callback); + + void onAddReactionListener(DirectItem item); + } + + public interface DirectItemInternalLongClickListener { + void onLongClick(int position, DirectItemViewHolder viewHolder); + } + + public interface DirectItemLongClickListener { + void onLongClick(int position); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectMessageInboxAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectMessageInboxAdapter.java new file mode 100644 index 0000000..18c1ba1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectMessageInboxAdapter.java @@ -0,0 +1,75 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.adapters.viewholder.directmessages.DirectInboxItemViewHolder; +import awais.instagrabber.databinding.LayoutDmInboxItemBinding; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; + +public final class DirectMessageInboxAdapter extends ListAdapter { + private final OnItemClickListener onClickListener; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final DirectThread oldItem, @NonNull final DirectThread newItem) { + return oldItem.getThreadId().equals(newItem.getThreadId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final DirectThread oldThread, + @NonNull final DirectThread newThread) { + final boolean titleEqual = oldThread.getThreadTitle().equals(newThread.getThreadTitle()); + if (!titleEqual) return false; + final boolean lastSeenAtEqual = Objects.equals(oldThread.getLastSeenAt(), newThread.getLastSeenAt()); + if (!lastSeenAtEqual) return false; + final List oldItems = oldThread.getItems(); + final List newItems = newThread.getItems(); + if (oldItems == null || newItems == null) return false; + if (oldItems.size() != newItems.size()) return false; + final DirectItem oldItemFirst = oldThread.getFirstDirectItem(); + final DirectItem newItemFirst = newThread.getFirstDirectItem(); + if (oldItemFirst == null || newItemFirst == null) return false; + final boolean idsEqual = oldItemFirst.getItemId().equals(newItemFirst.getItemId()); + if (!idsEqual) return false; + return oldItemFirst.getTimestamp() == newItemFirst.getTimestamp(); + } + }; + + public DirectMessageInboxAdapter(final OnItemClickListener onClickListener) { + super(new AsyncDifferConfig.Builder<>(diffCallback).build()); + this.onClickListener = onClickListener; + } + + @NonNull + @Override + public DirectInboxItemViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final LayoutDmInboxItemBinding binding = LayoutDmInboxItemBinding.inflate(layoutInflater, parent, false); + return new DirectInboxItemViewHolder(binding, onClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final DirectInboxItemViewHolder holder, final int position) { + final DirectThread thread = getItem(position); + holder.bind(thread); + } + + @Override + public long getItemId(final int position) { + return getItem(position).getThreadId().hashCode(); + } + + public interface OnItemClickListener { + void onItemClick(final DirectThread thread); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectPendingUsersAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectPendingUsersAdapter.java new file mode 100644 index 0000000..cf50c7d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectPendingUsersAdapter.java @@ -0,0 +1,117 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.adapters.viewholder.directmessages.DirectPendingUserViewHolder; +import awais.instagrabber.databinding.LayoutDmPendingUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectThreadParticipantRequestsResponse; + +public final class DirectPendingUsersAdapter extends ListAdapter { + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final PendingUser oldItem, @NonNull final PendingUser newItem) { + return oldItem.user.getPk() == newItem.user.getPk(); + } + + @Override + public boolean areContentsTheSame(@NonNull final PendingUser oldItem, @NonNull final PendingUser newItem) { + return Objects.equals(oldItem.user.getUsername(), newItem.user.getUsername()) && + Objects.equals(oldItem.user.getFullName(), newItem.user.getFullName()) && + Objects.equals(oldItem.requester, newItem.requester); + } + }; + + private final PendingUserCallback callback; + + public DirectPendingUsersAdapter(final PendingUserCallback callback) { + super(DIFF_CALLBACK); + this.callback = callback; + setHasStableIds(true); + } + + public void submitPendingRequests(final DirectThreadParticipantRequestsResponse requests) { + if (requests == null || requests.getUsers() == null) { + submitList(Collections.emptyList()); + return; + } + submitList(parse(requests)); + } + + private List parse(final DirectThreadParticipantRequestsResponse requests) { + final List users = requests.getUsers(); + final Map requesterUsernames = requests.getRequesterUsernames(); + return users.stream() + .map(user -> new PendingUser(user, requesterUsernames.get(user.getPk()))) + .collect(Collectors.toList()); + } + + @NonNull + @Override + public DirectPendingUserViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final LayoutDmPendingUserItemBinding binding = LayoutDmPendingUserItemBinding.inflate(layoutInflater, parent, false); + return new DirectPendingUserViewHolder(binding, callback); + } + + @Override + public void onBindViewHolder(@NonNull final DirectPendingUserViewHolder holder, final int position) { + final PendingUser pendingUser = getItem(position); + holder.bind(position, pendingUser); + } + + @Override + public long getItemId(final int position) { + final PendingUser item = getItem(position); + return item.user.getPk(); + } + + public static class PendingUser { + private final User user; + private final String requester; + + private boolean inProgress; + + public PendingUser(final User user, final String requester) { + this.user = user; + this.requester = requester; + } + + public User getUser() { + return user; + } + + public String getRequester() { + return requester; + } + + public boolean isInProgress() { + return inProgress; + } + + public PendingUser setInProgress(final boolean inProgress) { + this.inProgress = inProgress; + return this; + } + } + + public interface PendingUserCallback { + void onClick(int position, PendingUser pendingUser); + + void onApprove(int position, PendingUser pendingUser); + + void onDeny(int position, PendingUser pendingUser); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectReactionsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectReactionsAdapter.java new file mode 100644 index 0000000..f7bf762 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectReactionsAdapter.java @@ -0,0 +1,81 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.List; + +import awais.instagrabber.adapters.viewholder.directmessages.DirectReactionViewHolder; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; + +public final class DirectReactionsAdapter extends ListAdapter { + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final DirectItemEmojiReaction oldItem, @NonNull final DirectItemEmojiReaction newItem) { + return oldItem.getSenderId() == newItem.getSenderId(); + } + + @Override + public boolean areContentsTheSame(@NonNull final DirectItemEmojiReaction oldItem, @NonNull final DirectItemEmojiReaction newItem) { + return oldItem.getEmoji().equals(newItem.getEmoji()); + } + }; + + private final long viewerId; + private final List users; + private final String itemId; + private final OnReactionClickListener onReactionClickListener; + + public DirectReactionsAdapter(final long viewerId, + final List users, + final String itemId, + final OnReactionClickListener onReactionClickListener) { + super(DIFF_CALLBACK); + this.viewerId = viewerId; + this.users = users; + this.itemId = itemId; + this.onReactionClickListener = onReactionClickListener; + setHasStableIds(true); + } + + @NonNull + @Override + public DirectReactionViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); + return new DirectReactionViewHolder(binding, viewerId, itemId, onReactionClickListener); + + } + + @Override + public void onBindViewHolder(@NonNull final DirectReactionViewHolder holder, final int position) { + final DirectItemEmojiReaction reaction = getItem(position); + if (reaction == null) return; + holder.bind(reaction, getUser(reaction.getSenderId())); + } + + @Override + public long getItemId(final int position) { + return getItem(position).getSenderId(); + } + + @Nullable + private User getUser(final long pk) { + return users.stream() + .filter(user -> user.getPk() == pk) + .findFirst() + .orElse(null); + } + + public interface OnReactionClickListener { + void onReactionClick(String itemId, DirectItemEmojiReaction reaction); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java new file mode 100644 index 0000000..4b86c42 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java @@ -0,0 +1,183 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.directmessages.DirectUserViewHolder; +import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.User; + +public final class DirectUsersAdapter extends ListAdapter { + + private static final int VIEW_TYPE_HEADER = 0; + private static final int VIEW_TYPE_USER = 1; + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final DirectUserOrHeader oldItem, @NonNull final DirectUserOrHeader newItem) { + final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); + final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); + boolean areSameType = bothHeaders || bothItems; + if (!areSameType) return false; + if (bothHeaders) { + return oldItem.headerTitle == newItem.headerTitle; + } + if (oldItem.user != null && newItem.user != null) { + return oldItem.user.getPk() == newItem.user.getPk(); + } + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull final DirectUserOrHeader oldItem, @NonNull final DirectUserOrHeader newItem) { + final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); + final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); + boolean areSameType = bothHeaders || bothItems; + if (!areSameType) return false; + if (bothHeaders) { + return oldItem.headerTitle == newItem.headerTitle; + } + if (oldItem.user != null && newItem.user != null) { + return oldItem.user.getUsername().equals(newItem.user.getUsername()) && + oldItem.user.getFullName().equals(newItem.user.getFullName()); + } + return false; + } + }; + + private final long inviterId; + private final OnDirectUserClickListener onClickListener; + private final OnDirectUserLongClickListener onLongClickListener; + private List adminUserIds; + + public DirectUsersAdapter(final long inviterId, + final OnDirectUserClickListener onClickListener, + final OnDirectUserLongClickListener onLongClickListener) { + super(DIFF_CALLBACK); + this.inviterId = inviterId; + this.onClickListener = onClickListener; + this.onLongClickListener = onLongClickListener; + setHasStableIds(true); + } + + public void submitUsers(final List users, final List leftUsers) { + if (users == null && leftUsers == null) return; + final List userOrHeaders = combineLists(users, leftUsers); + submitList(userOrHeaders); + } + + private List combineLists(final List users, final List leftUsers) { + final ImmutableList.Builder listBuilder = ImmutableList.builder(); + if (users != null && !users.isEmpty()) { + listBuilder.add(new DirectUserOrHeader(R.string.members)); + users.stream() + .map(DirectUserOrHeader::new) + .forEach(listBuilder::add); + } + if (leftUsers != null && !leftUsers.isEmpty()) { + listBuilder.add(new DirectUserOrHeader(R.string.dms_left_users)); + leftUsers.stream() + .map(DirectUserOrHeader::new) + .forEach(listBuilder::add); + } + return listBuilder.build(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case VIEW_TYPE_USER: + final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); + return new DirectUserViewHolder(binding, onClickListener, onLongClickListener); + case VIEW_TYPE_HEADER: + default: + final ItemFavSectionHeaderBinding headerBinding = ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false); + return new HeaderViewHolder(headerBinding); + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + if (holder instanceof HeaderViewHolder) { + ((HeaderViewHolder) holder).bind(getItem(position).headerTitle); + return; + } + if (holder instanceof DirectUserViewHolder) { + final User user = getItem(position).user; + ((DirectUserViewHolder) holder).bind(position, + user, + user != null && adminUserIds != null && adminUserIds.contains(user.getPk()), + user != null && user.getPk() == inviterId, + false, + false); + } + } + + @Override + public int getItemViewType(final int position) { + final DirectUserOrHeader item = getItem(position); + return item.isHeader() ? VIEW_TYPE_HEADER : VIEW_TYPE_USER; + } + + @Override + public long getItemId(final int position) { + final DirectUserOrHeader item = getItem(position); + return item.isHeader() ? item.headerTitle : item.user.getPk(); + } + + public void setAdminUserIds(final List adminUserIds) { + this.adminUserIds = adminUserIds; + notifyDataSetChanged(); + } + + public static class DirectUserOrHeader { + int headerTitle; + User user; + + public DirectUserOrHeader(final int headerTitle) { + this.headerTitle = headerTitle; + } + + public DirectUserOrHeader(final User user) { + this.user = user; + } + + boolean isHeader() { + return headerTitle > 0; + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + private final ItemFavSectionHeaderBinding binding; + + public HeaderViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(@StringRes final int headerTitle) { + binding.getRoot().setText(headerTitle); + } + } + + public interface OnDirectUserClickListener { + void onClick(int position, User user, boolean selected); + } + + public interface OnDirectUserLongClickListener { + boolean onLongClick(int position, User user); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectoryFilesAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectoryFilesAdapter.java new file mode 100644 index 0000000..13fca19 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectoryFilesAdapter.java @@ -0,0 +1,75 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import java.io.File; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.ItemDirListBinding; + +public final class DirectoryFilesAdapter extends ListAdapter { + private final OnFileClickListener onFileClickListener; + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final File oldItem, @NonNull final File newItem) { + return oldItem.getAbsolutePath().equals(newItem.getAbsolutePath()); + } + + @Override + public boolean areContentsTheSame(@NonNull final File oldItem, @NonNull final File newItem) { + return oldItem.getAbsolutePath().equals(newItem.getAbsolutePath()); + } + }; + + public DirectoryFilesAdapter(final OnFileClickListener onFileClickListener) { + super(DIFF_CALLBACK); + this.onFileClickListener = onFileClickListener; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final ItemDirListBinding binding = ItemDirListBinding.inflate(inflater, parent, false); + return new ViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { + final File file = getItem(position); + holder.bind(file, onFileClickListener); + } + + public interface OnFileClickListener { + void onFileClick(File file); + } + + static final class ViewHolder extends RecyclerView.ViewHolder { + private final ItemDirListBinding binding; + + private ViewHolder(final ItemDirListBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final File file, final OnFileClickListener onFileClickListener) { + if (file == null) return; + if (onFileClickListener != null) { + itemView.setOnClickListener(v -> onFileClickListener.onFileClick(file)); + } + binding.text.setText(file.getName()); + if (file.isDirectory()) { + binding.icon.setImageResource(R.drawable.ic_folder_24); + return; + } + binding.icon.setImageResource(R.drawable.ic_file_24); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java new file mode 100644 index 0000000..a1547a0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java @@ -0,0 +1,58 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import awais.instagrabber.adapters.viewholder.TopicClusterViewHolder; +import awais.instagrabber.databinding.ItemDiscoverTopicBinding; +import awais.instagrabber.repositories.responses.discover.TopicCluster; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.ResponseBodyUtils; + +public class DiscoverTopicsAdapter extends ListAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final TopicCluster oldItem, @NonNull final TopicCluster newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final TopicCluster oldItem, @NonNull final TopicCluster newItem) { + final String oldThumbUrl = ResponseBodyUtils.getThumbUrl(oldItem.getCoverMedia()); + return oldThumbUrl != null && oldThumbUrl.equals(ResponseBodyUtils.getThumbUrl(newItem.getCoverMedia())) + && oldItem.getTitle().equals(newItem.getTitle()); + } + }; + + private final OnTopicClickListener onTopicClickListener; + + public DiscoverTopicsAdapter(final OnTopicClickListener onTopicClickListener) { + super(DIFF_CALLBACK); + this.onTopicClickListener = onTopicClickListener; + } + + @NonNull + @Override + public TopicClusterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemDiscoverTopicBinding binding = ItemDiscoverTopicBinding.inflate(layoutInflater, parent, false); + return new TopicClusterViewHolder(binding, onTopicClickListener, null); + } + + @Override + public void onBindViewHolder(@NonNull final TopicClusterViewHolder holder, final int position) { + final TopicCluster topicCluster = getItem(position); + holder.bind(topicCluster); + } + + public interface OnTopicClickListener { + void onTopicClick(TopicCluster topicCluster, View cover, int titleColor, int backgroundColor); + + void onTopicLongClick(Media coverMedia); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java new file mode 100644 index 0000000..b9d89ce --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java @@ -0,0 +1,202 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.FavoriteViewHolder; +import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; +import awais.instagrabber.databinding.ItemSearchResultBinding; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.models.enums.FavoriteType; + +public class FavoritesAdapter extends RecyclerView.Adapter { + + private final OnFavoriteClickListener clickListener; + private final OnFavoriteLongClickListener longClickListener; + private final AsyncListDiffer differ; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final FavoriteModelOrHeader oldItem, @NonNull final FavoriteModelOrHeader newItem) { + boolean areSame = oldItem.isHeader() && newItem.isHeader(); + if (!areSame) { + return false; + } + if (oldItem.isHeader()) { + return ObjectsCompat.equals(oldItem.header, newItem.header); + } + if (oldItem.model != null && newItem.model != null) { + return oldItem.model.getId() == newItem.model.getId(); + } + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull final FavoriteModelOrHeader oldItem, @NonNull final FavoriteModelOrHeader newItem) { + boolean areSame = oldItem.isHeader() && newItem.isHeader(); + if (!areSame) { + return false; + } + if (oldItem.isHeader()) { + return ObjectsCompat.equals(oldItem.header, newItem.header); + } + return ObjectsCompat.equals(oldItem.model, newItem.model); + } + }; + + public FavoritesAdapter(final OnFavoriteClickListener clickListener, final OnFavoriteLongClickListener longClickListener) { + this.clickListener = clickListener; + this.longClickListener = longClickListener; + differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(diffCallback).build()); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (viewType == 0) { + // header + return new FavSectionViewHolder(ItemFavSectionHeaderBinding.inflate(inflater, parent, false)); + } + final ItemSearchResultBinding binding = ItemSearchResultBinding.inflate(inflater, parent, false); + return new FavoriteViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + if (getItemViewType(position) == 0) { + final FavoriteModelOrHeader modelOrHeader = getItem(position); + if (!modelOrHeader.isHeader()) return; + ((FavSectionViewHolder) holder).bind(modelOrHeader.header); + return; + } + ((FavoriteViewHolder) holder).bind(getItem(position).model, clickListener, longClickListener); + } + + protected FavoriteModelOrHeader getItem(int position) { + return differ.getCurrentList().get(position); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public int getItemViewType(final int position) { + return getItem(position).isHeader() ? 0 : 1; + } + + public void submitList(@Nullable final List list) { + if (list == null) { + differ.submitList(null); + return; + } + differ.submitList(sectionAndSort(list)); + } + + public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { + if (list == null) { + differ.submitList(null, commitCallback); + return; + } + differ.submitList(sectionAndSort(list), commitCallback); + } + + @NonNull + private List sectionAndSort(@NonNull final List list) { + final List listCopy = new ArrayList<>(list); + Collections.sort(listCopy, (o1, o2) -> { + if (o1.getType() == o2.getType()) return 0; + // keep users at top + if (o1.getType() == FavoriteType.USER) return -1; + if (o2.getType() == FavoriteType.USER) return 1; + // keep locations at bottom + if (o1.getType() == FavoriteType.LOCATION) return 1; + if (o2.getType() == FavoriteType.LOCATION) return -1; + return 0; + }); + final List modelOrHeaders = new ArrayList<>(); + for (int i = 0; i < listCopy.size(); i++) { + final Favorite model = listCopy.get(i); + final FavoriteModelOrHeader prev = modelOrHeaders.isEmpty() ? null : modelOrHeaders.get(modelOrHeaders.size() - 1); + boolean prevWasSameType = prev != null && prev.model.getType() == model.getType(); + if (prevWasSameType) { + // just add model + final FavoriteModelOrHeader modelOrHeader = new FavoriteModelOrHeader(); + modelOrHeader.model = model; + modelOrHeaders.add(modelOrHeader); + continue; + } + // add header and model + FavoriteModelOrHeader modelOrHeader = new FavoriteModelOrHeader(); + modelOrHeader.header = model.getType(); + modelOrHeaders.add(modelOrHeader); + modelOrHeader = new FavoriteModelOrHeader(); + modelOrHeader.model = model; + modelOrHeaders.add(modelOrHeader); + } + return modelOrHeaders; + } + + private static class FavoriteModelOrHeader { + FavoriteType header; + Favorite model; + + boolean isHeader() { + return header != null; + } + } + + public interface OnFavoriteClickListener { + void onClick(final Favorite model); + } + + public interface OnFavoriteLongClickListener { + boolean onLongClick(final Favorite model); + } + + public static class FavSectionViewHolder extends RecyclerView.ViewHolder { + private final ItemFavSectionHeaderBinding binding; + + public FavSectionViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final FavoriteType header) { + if (header == null) return; + final int headerText; + switch (header) { + case USER: + headerText = R.string.accounts; + break; + case HASHTAG: + headerText = R.string.hashtags; + break; + case LOCATION: + headerText = R.string.locations; + break; + default: + headerText = R.string.unknown; + break; + } + binding.getRoot().setText(headerText); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/FeedAdapterV2.java b/app/src/main/java/awais/instagrabber/adapters/FeedAdapterV2.java new file mode 100644 index 0000000..0090de1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FeedAdapterV2.java @@ -0,0 +1,247 @@ +package awais.instagrabber.adapters; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import awais.instagrabber.adapters.viewholder.FeedGridItemViewHolder; +import awais.instagrabber.adapters.viewholder.feed.FeedItemViewHolder; +import awais.instagrabber.adapters.viewholder.feed.FeedPhotoViewHolder; +import awais.instagrabber.adapters.viewholder.feed.FeedSliderViewHolder; +import awais.instagrabber.adapters.viewholder.feed.FeedVideoViewHolder; +import awais.instagrabber.databinding.ItemFeedGridBinding; +import awais.instagrabber.databinding.ItemFeedPhotoBinding; +import awais.instagrabber.databinding.ItemFeedSliderBinding; +import awais.instagrabber.databinding.ItemFeedVideoBinding; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Caption; +import awais.instagrabber.repositories.responses.Media; + +public final class FeedAdapterV2 extends ListAdapter { + private static final String TAG = "FeedAdapterV2"; + + private final FeedItemCallback feedItemCallback; + private final SelectionModeCallback selectionModeCallback; + private final Set selectedPositions = new HashSet<>(); + private final Set selectedFeedModels = new HashSet<>(); + + private PostsLayoutPreferences layoutPreferences; + private boolean selectionModeActive = false; + + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { + return Objects.equals(oldItem.getPk(), newItem.getPk()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { + final Caption oldItemCaption = oldItem.getCaption(); + final Caption newItemCaption = newItem.getCaption(); + return Objects.equals(oldItem.getPk(), newItem.getPk()) + && Objects.equals(getCaptionText(oldItemCaption), getCaptionText(newItemCaption)); + } + + private String getCaptionText(final Caption caption) { + if (caption == null) return null; + return caption.getText(); + } + }; + private final AdapterSelectionCallback adapterSelectionCallback = new AdapterSelectionCallback() { + @Override + public boolean onPostLongClick(final int position, final Media feedModel) { + if (!selectionModeActive) { + selectionModeActive = true; + notifyDataSetChanged(); + if (selectionModeCallback != null) { + selectionModeCallback.onSelectionStart(); + } + } + selectedPositions.add(position); + selectedFeedModels.add(feedModel); + notifyItemChanged(position); + if (selectionModeCallback != null) { + selectionModeCallback.onSelectionChange(selectedFeedModels); + } + return true; + } + + @Override + public void onPostClick(final int position, final Media feedModel) { + if (!selectionModeActive) return; + if (selectedPositions.contains(position)) { + selectedPositions.remove(position); + selectedFeedModels.remove(feedModel); + } else { + selectedPositions.add(position); + selectedFeedModels.add(feedModel); + } + notifyItemChanged(position); + if (selectionModeCallback != null) { + selectionModeCallback.onSelectionChange(selectedFeedModels); + } + if (selectedPositions.isEmpty()) { + selectionModeActive = false; + notifyDataSetChanged(); + if (selectionModeCallback != null) { + selectionModeCallback.onSelectionEnd(); + } + } + } + }; + + public FeedAdapterV2(@NonNull final PostsLayoutPreferences layoutPreferences, + final FeedItemCallback feedItemCallback, + final SelectionModeCallback selectionModeCallback) { + super(DIFF_CALLBACK); + this.layoutPreferences = layoutPreferences; + this.feedItemCallback = feedItemCallback; + this.selectionModeCallback = selectionModeCallback; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final Context context = parent.getContext(); + final LayoutInflater layoutInflater = LayoutInflater.from(context); + switch (layoutPreferences.getType()) { + case LINEAR: + return getLinearViewHolder(parent, layoutInflater, viewType); + case GRID: + case STAGGERED_GRID: + default: + final ItemFeedGridBinding binding = ItemFeedGridBinding.inflate(layoutInflater, parent, false); + return new FeedGridItemViewHolder(binding); + } + } + + @NonNull + private RecyclerView.ViewHolder getLinearViewHolder(@NonNull final ViewGroup parent, + final LayoutInflater layoutInflater, + final int viewType) { + switch (MediaItemType.valueOf(viewType)) { + case MEDIA_TYPE_VIDEO: { + final ItemFeedVideoBinding binding = ItemFeedVideoBinding.inflate(layoutInflater, parent, false); + return new FeedVideoViewHolder(binding, feedItemCallback); + } + case MEDIA_TYPE_SLIDER: { + final ItemFeedSliderBinding binding = ItemFeedSliderBinding.inflate(layoutInflater, parent, false); + return new FeedSliderViewHolder(binding, feedItemCallback); + } + case MEDIA_TYPE_IMAGE: + default: { + final ItemFeedPhotoBinding binding = ItemFeedPhotoBinding.inflate(layoutInflater, parent, false); + return new FeedPhotoViewHolder(binding, feedItemCallback); + } + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, final int position) { + final Media feedModel = getItem(position); + if (feedModel == null) return; + switch (layoutPreferences.getType()) { + case LINEAR: + ((FeedItemViewHolder) viewHolder).bind(feedModel); + break; + case GRID: + case STAGGERED_GRID: + default: + ((FeedGridItemViewHolder) viewHolder).bind(position, + feedModel, + layoutPreferences, + feedItemCallback, + adapterSelectionCallback, + selectionModeActive, + selectedPositions.contains(position)); + } + } + + @Override + public int getItemViewType(final int position) { + return getItem(position).getType().getId(); + } + + public void setLayoutPreferences(@NonNull final PostsLayoutPreferences layoutPreferences) { + this.layoutPreferences = layoutPreferences; + } + + public void endSelection() { + if (!selectionModeActive) return; + selectionModeActive = false; + selectedPositions.clear(); + selectedFeedModels.clear(); + notifyDataSetChanged(); + if (selectionModeCallback != null) { + selectionModeCallback.onSelectionEnd(); + } + } + + // @Override + // public void onViewAttachedToWindow(@NonNull final FeedItemViewHolder holder) { + // super.onViewAttachedToWindow(holder); + // // Log.d(TAG, "attached holder: " + holder); + // if (!(holder instanceof FeedSliderViewHolder)) return; + // final FeedSliderViewHolder feedSliderViewHolder = (FeedSliderViewHolder) holder; + // feedSliderViewHolder.startPlayingVideo(); + // } + // + // @Override + // public void onViewDetachedFromWindow(@NonNull final FeedItemViewHolder holder) { + // super.onViewDetachedFromWindow(holder); + // // Log.d(TAG, "detached holder: " + holder); + // if (!(holder instanceof FeedSliderViewHolder)) return; + // final FeedSliderViewHolder feedSliderViewHolder = (FeedSliderViewHolder) holder; + // feedSliderViewHolder.stopPlayingVideo(); + // } + + public interface FeedItemCallback { + void onPostClick(final Media feedModel); + + void onProfilePicClick(final Media feedModel); + + void onNameClick(final Media feedModel); + + void onLocationClick(final Media feedModel); + + void onMentionClick(final String mention); + + void onHashtagClick(final String hashtag); + + void onCommentsClick(final Media feedModel); + + void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation); + + void onEmailClick(final String emailId); + + void onURLClick(final String url); + + void onSliderClick(Media feedModel, int position); + } + + public interface AdapterSelectionCallback { + boolean onPostLongClick(final int position, Media feedModel); + + void onPostClick(final int position, Media feedModel); + } + + public interface SelectionModeCallback { + void onSelectionStart(); + + void onSelectionChange(final Set selectedFeedModels); + + void onSelectionEnd(); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/FeedItemCallbackAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FeedItemCallbackAdapter.java new file mode 100644 index 0000000..b7e2276 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FeedItemCallbackAdapter.java @@ -0,0 +1,41 @@ +package awais.instagrabber.adapters; + +import android.view.View; + +import awais.instagrabber.repositories.responses.Media; + + +public class FeedItemCallbackAdapter implements FeedAdapterV2.FeedItemCallback { + @Override + public void onPostClick(final Media media) {} + + @Override + public void onProfilePicClick(final Media media) {} + + @Override + public void onNameClick(final Media media) {} + + @Override + public void onLocationClick(final Media media) {} + + @Override + public void onMentionClick(final String mention) {} + + @Override + public void onHashtagClick(final String hashtag) {} + + @Override + public void onCommentsClick(final Media media) {} + + @Override + public void onDownloadClick(final Media media, final int childPosition, final View popupLocation) {} + + @Override + public void onEmailClick(final String emailId) {} + + @Override + public void onURLClick(final String url) {} + + @Override + public void onSliderClick(final Media media, final int position) {} +} diff --git a/app/src/main/java/awais/instagrabber/adapters/FeedStoriesAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FeedStoriesAdapter.java new file mode 100755 index 0000000..821a1c7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FeedStoriesAdapter.java @@ -0,0 +1,53 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import awais.instagrabber.adapters.viewholder.FeedStoryViewHolder; +import awais.instagrabber.databinding.ItemHighlightBinding; +import awais.instagrabber.repositories.responses.stories.Story; + +public final class FeedStoriesAdapter extends ListAdapter { + private final OnFeedStoryClickListener listener; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()) && oldItem.getSeen() == newItem.getSeen(); + } + }; + + public FeedStoriesAdapter(final OnFeedStoryClickListener listener) { + super(diffCallback); + this.listener = listener; + } + + @NonNull + @Override + public FeedStoryViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemHighlightBinding binding = ItemHighlightBinding.inflate(layoutInflater, parent, false); + return new FeedStoryViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final FeedStoryViewHolder holder, final int position) { + final Story model = getItem(position); + holder.bind(model, position, listener); + } + + public interface OnFeedStoryClickListener { + void onFeedStoryClick(Story model, int position); + + void onFeedStoryLongClick(Story model, int position); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/FeedStoriesListAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FeedStoriesListAdapter.java new file mode 100755 index 0000000..4ca8935 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FeedStoriesListAdapter.java @@ -0,0 +1,105 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.Filter; +import android.widget.Filterable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.List; +import java.util.stream.Collectors; + +import awais.instagrabber.adapters.viewholder.StoryListViewHolder; +import awais.instagrabber.databinding.ItemNotificationBinding; +import awais.instagrabber.repositories.responses.stories.Story; +import awais.instagrabber.utils.TextUtils; + +public final class FeedStoriesListAdapter extends ListAdapter implements Filterable { + private final OnFeedStoryClickListener listener; + private List list; + + private final Filter filter = new Filter() { + @NonNull + @Override + protected FilterResults performFiltering(final CharSequence filter) { + final String query = TextUtils.isEmpty(filter) ? null : filter.toString().toLowerCase(); + List filteredList = list; + if (list != null && query != null) { + filteredList = list.stream() + .filter(feedStoryModel -> feedStoryModel.getUser() + .getUsername() + .toLowerCase() + .contains(query)) + .collect(Collectors.toList()); + } + final FilterResults filterResults = new FilterResults(); + filterResults.count = filteredList != null ? filteredList.size() : 0; + filterResults.values = filteredList; + return filterResults; + } + + @Override + protected void publishResults(final CharSequence constraint, final FilterResults results) { + //noinspection unchecked + submitList((List) results.values, true); + } + }; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()) && oldItem.getSeen() == newItem.getSeen(); + } + }; + + public FeedStoriesListAdapter(final OnFeedStoryClickListener listener) { + super(diffCallback); + this.listener = listener; + } + + @Override + public Filter getFilter() { + return filter; + } + + private void submitList(@Nullable final List list, final boolean isFiltered) { + if (!isFiltered) { + this.list = list; + } + super.submitList(list); + } + + @Override + public void submitList(final List list) { + submitList(list, false); + } + + @NonNull + @Override + public StoryListViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemNotificationBinding binding = ItemNotificationBinding.inflate(layoutInflater, parent, false); + return new StoryListViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final StoryListViewHolder holder, final int position) { + final Story model = getItem(position); + holder.bind(model, listener); + } + + public interface OnFeedStoryClickListener { + void onFeedStoryClick(final Story model); + + void onProfileClick(final String username); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/FiltersAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FiltersAdapter.java new file mode 100644 index 0000000..4a467a4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FiltersAdapter.java @@ -0,0 +1,94 @@ +package awais.instagrabber.adapters; + +import android.graphics.Bitmap; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.Collection; +import java.util.List; + +import awais.instagrabber.adapters.viewholder.FilterViewHolder; +import awais.instagrabber.databinding.ItemFilterBinding; +import awais.instagrabber.fragments.imageedit.filters.filters.Filter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; + +public class FiltersAdapter extends ListAdapter, FilterViewHolder> { + + private static final DiffUtil.ItemCallback> DIFF_CALLBACK = new DiffUtil.ItemCallback>() { + @Override + public boolean areItemsTheSame(@NonNull final Filter oldItem, @NonNull final Filter newItem) { + return oldItem.getType().equals(newItem.getType()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Filter oldItem, @NonNull final Filter newItem) { + return oldItem.getType().equals(newItem.getType()); + } + }; + + private final Bitmap bitmap; + private final OnFilterClickListener onFilterClickListener; + private final Collection filters; + private final String originalKey; + private int selectedPosition = 0; + + public FiltersAdapter(final Collection filters, + final String originalKey, + final Bitmap bitmap, + final OnFilterClickListener onFilterClickListener) { + super(DIFF_CALLBACK); + this.filters = filters; + this.originalKey = originalKey; + this.bitmap = bitmap; + this.onFilterClickListener = onFilterClickListener; + setHasStableIds(true); + } + + @NonNull + @Override + public FilterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemFilterBinding binding = ItemFilterBinding.inflate(layoutInflater, parent, false); + return new FilterViewHolder(binding, filters, onFilterClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final FilterViewHolder holder, final int position) { + holder.bind(position, originalKey, bitmap, getItem(position), selectedPosition == position); + } + + @Override + public long getItemId(final int position) { + return getItem(position).getLabel(); + } + + public void setSelected(final int position) { + final int prev = this.selectedPosition; + this.selectedPosition = position; + notifyItemChanged(position); + notifyItemChanged(prev); + } + + public void setSelectedFilter(final GPUImageFilter instance) { + final List> currentList = getCurrentList(); + int index = -1; + for (int i = 0; i < currentList.size(); i++) { + final Filter filter = currentList.get(i); + final GPUImageFilter filterInstance = filter.getInstance(); + if (filterInstance.getClass() == instance.getClass()) { + index = i; + break; + } + } + if (index < 0) return; + setSelected(index); + } + + public interface OnFilterClickListener { + void onClick(int position, Filter filter); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/FollowAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FollowAdapter.java new file mode 100755 index 0000000..d8368aa --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/FollowAdapter.java @@ -0,0 +1,152 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Filter; +import android.widget.Filterable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.FollowsViewHolder; +import awais.instagrabber.databinding.ItemFollowBinding; +import awais.instagrabber.interfaces.OnGroupClickListener; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.TextUtils; +import thoughtbot.expandableadapter.ExpandableGroup; +import thoughtbot.expandableadapter.ExpandableList; +import thoughtbot.expandableadapter.ExpandableListPosition; +import thoughtbot.expandableadapter.GroupViewHolder; + +// thanks to ThoughtBot's ExpandableRecyclerViewAdapter +// https://github.com/thoughtbot/expandable-recycler-view +public final class FollowAdapter extends RecyclerView.Adapter implements OnGroupClickListener, Filterable { + private final View.OnClickListener onClickListener; + private final ExpandableList expandableListOriginal; + private final boolean hasManyGroups; + private ExpandableList expandableList; + + private final Filter filter = new Filter() { + @Nullable + @Override + protected FilterResults performFiltering(final CharSequence filter) { + final List filteredItems = new ArrayList(); + if (expandableListOriginal.groups == null || TextUtils.isEmpty(filter)) return null; + final String query = filter.toString().toLowerCase(); + final ArrayList groups = new ArrayList(); + for (int x = 0; x < expandableListOriginal.groups.size(); ++x) { + final ExpandableGroup expandableGroup = expandableListOriginal.groups.get(x); + final String title = expandableGroup.getTitle(); + final List items = expandableGroup.getItems(); + if (items != null) { + final List toReturn = items.stream() + .filter(u -> hasKey(query, u.getUsername(), u.getFullName())) + .collect(Collectors.toList()); + groups.add(new ExpandableGroup(title, toReturn)); + } + } + final FilterResults filterResults = new FilterResults(); + filterResults.values = new ExpandableList(groups, expandableList.expandedGroupIndexes); + return filterResults; + } + + private boolean hasKey(final String key, final String username, final String name) { + if (TextUtils.isEmpty(key)) return true; + final boolean hasUserName = username != null && username.toLowerCase().contains(key); + if (!hasUserName && name != null) return name.toLowerCase().contains(key); + return true; + } + + @Override + protected void publishResults(final CharSequence constraint, final FilterResults results) { + if (results == null) { + expandableList = expandableListOriginal; + } + else { + final ExpandableList filteredList = (ExpandableList) results.values; + expandableList = filteredList; + } + notifyDataSetChanged(); + } + }; + + public FollowAdapter(final View.OnClickListener onClickListener, @NonNull final ArrayList groups) { + this.expandableListOriginal = new ExpandableList(groups); + expandableList = this.expandableListOriginal; + this.onClickListener = onClickListener; + this.hasManyGroups = groups.size() > 1; + } + + @Override + public Filter getFilter() { + return filter; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final boolean isGroup = hasManyGroups && viewType == ExpandableListPosition.GROUP; + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final View view; + if (isGroup) { + view = layoutInflater.inflate(R.layout.header_follow, parent, false); + return new GroupViewHolder(view, this); + } else { + final ItemFollowBinding binding = ItemFollowBinding.inflate(layoutInflater, parent, false); + return new FollowsViewHolder(binding); + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + final ExpandableListPosition listPos = expandableList.getUnflattenedPosition(position); + final ExpandableGroup group = expandableList.getExpandableGroup(listPos); + + if (hasManyGroups && listPos.type == ExpandableListPosition.GROUP) { + final GroupViewHolder gvh = (GroupViewHolder) holder; + gvh.setTitle(group.getTitle()); + gvh.toggle(isGroupExpanded(group)); + return; + } + final User model = group.getItems().get(hasManyGroups ? listPos.childPos : position); + ((FollowsViewHolder) holder).bind(model, onClickListener); + } + + @Override + public int getItemCount() { + return expandableList.getVisibleItemCount() - (hasManyGroups ? 0 : 1); + } + + @Override + public int getItemViewType(final int position) { + return !hasManyGroups ? 0 : expandableList.getUnflattenedPosition(position).type; + } + + @Override + public void toggleGroup(final int flatPos) { + final ExpandableListPosition listPosition = expandableList.getUnflattenedPosition(flatPos); + + final int groupPos = listPosition.groupPos; + final int positionStart = expandableList.getFlattenedGroupIndex(listPosition) + 1; + final int positionEnd = expandableList.groups.get(groupPos).getItemCount(); + + final boolean isExpanded = expandableList.expandedGroupIndexes[groupPos]; + expandableList.expandedGroupIndexes[groupPos] = !isExpanded; + notifyItemChanged(positionStart - 1); + if (positionEnd > 0) { + if (isExpanded) notifyItemRangeRemoved(positionStart, positionEnd); + else notifyItemRangeInserted(positionStart, positionEnd); + } + } + + public boolean isGroupExpanded(final ExpandableGroup group) { + return expandableList.expandedGroupIndexes[expandableList.groups.indexOf(group)]; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/GifItemsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/GifItemsAdapter.java new file mode 100644 index 0000000..83a6f62 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/GifItemsAdapter.java @@ -0,0 +1,107 @@ +package awais.instagrabber.adapters; + +import android.net.Uri; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.imagepipeline.common.ResizeOptions; +import com.facebook.imagepipeline.image.ImageInfo; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; + +import java.util.Objects; + +import awais.instagrabber.databinding.ItemMediaBinding; +import awais.instagrabber.repositories.responses.giphy.GiphyGif; +import awais.instagrabber.utils.Utils; + +public class GifItemsAdapter extends ListAdapter { + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final GiphyGif oldItem, @NonNull final GiphyGif newItem) { + return Objects.equals(oldItem.getId(), newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final GiphyGif oldItem, @NonNull final GiphyGif newItem) { + return Objects.equals(oldItem.getId(), newItem.getId()); + } + }; + + private final OnItemClickListener onItemClickListener; + + public GifItemsAdapter(final OnItemClickListener onItemClickListener) { + super(diffCallback); + this.onItemClickListener = onItemClickListener; + } + + @NonNull + @Override + public GifViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemMediaBinding binding = ItemMediaBinding.inflate(layoutInflater, parent, false); + return new GifViewHolder(binding, onItemClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final GifViewHolder holder, final int position) { + holder.bind(getItem(position)); + } + + public static class GifViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = GifViewHolder.class.getSimpleName(); + private static final int size = Utils.displayMetrics.widthPixels / 3; + + private final ItemMediaBinding binding; + private final OnItemClickListener onItemClickListener; + + public GifViewHolder(@NonNull final ItemMediaBinding binding, + final OnItemClickListener onItemClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onItemClickListener = onItemClickListener; + binding.duration.setVisibility(View.GONE); + final GenericDraweeHierarchyBuilder builder = new GenericDraweeHierarchyBuilder(itemView.getResources()); + builder.setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER); + binding.item.setHierarchy(builder.build()); + } + + public void bind(final GiphyGif item) { + if (onItemClickListener != null) { + itemView.setOnClickListener(v -> onItemClickListener.onItemClick(item)); + } + final BaseControllerListener controllerListener = new BaseControllerListener() { + @Override + public void onFailure(final String id, final Throwable throwable) { + Log.e(TAG, "onFailure: ", throwable); + } + }; + final ImageRequest request = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(item.getImages().getFixedHeight().getWebp())) + .setResizeOptions(ResizeOptions.forDimensions(size, size)) + .build(); + final PipelineDraweeControllerBuilder builder = Fresco.newDraweeControllerBuilder() + .setImageRequest(request) + .setAutoPlayAnimations(true) + .setControllerListener(controllerListener); + binding.item.setController(builder.build()); + } + } + + public interface OnItemClickListener { + void onItemClick(GiphyGif giphyGif); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/HighlightStoriesListAdapter.java b/app/src/main/java/awais/instagrabber/adapters/HighlightStoriesListAdapter.java new file mode 100755 index 0000000..eabfe73 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/HighlightStoriesListAdapter.java @@ -0,0 +1,53 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import awais.instagrabber.adapters.viewholder.StoryListViewHolder; +import awais.instagrabber.databinding.ItemNotificationBinding; +import awais.instagrabber.repositories.responses.stories.Story; + +public final class HighlightStoriesListAdapter extends ListAdapter { + private final OnHighlightStoryClickListener listener; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()); + } + }; + + public HighlightStoriesListAdapter(final OnHighlightStoryClickListener listener) { + super(diffCallback); + this.listener = listener; + } + + @NonNull + @Override + public StoryListViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemNotificationBinding binding = ItemNotificationBinding.inflate(layoutInflater, parent, false); + return new StoryListViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final StoryListViewHolder holder, final int position) { + final Story model = getItem(position); + holder.bind(model, position, listener); + } + + public interface OnHighlightStoryClickListener { + void onHighlightClick(final Story model, final int position); + + void onProfileClick(final String username); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/HighlightsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/HighlightsAdapter.java new file mode 100755 index 0000000..f97c609 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/HighlightsAdapter.java @@ -0,0 +1,55 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import awais.instagrabber.adapters.viewholder.HighlightViewHolder; +import awais.instagrabber.databinding.ItemHighlightBinding; +import awais.instagrabber.repositories.responses.stories.Story; + +public final class HighlightsAdapter extends ListAdapter { + + private final OnHighlightClickListener clickListener; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { + return oldItem.getId().equals(newItem.getId()); + } + }; + + public HighlightsAdapter(final OnHighlightClickListener clickListener) { + super(diffCallback); + this.clickListener = clickListener; + } + + @NonNull + @Override + public HighlightViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemHighlightBinding binding = ItemHighlightBinding.inflate(layoutInflater, parent, false); + return new HighlightViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final HighlightViewHolder holder, final int position) { + final Story highlightModel = getItem(position); + if (clickListener != null) { + holder.itemView.setOnClickListener(v -> clickListener.onHighlightClick(highlightModel, position)); + } + holder.bind(highlightModel); + } + + public interface OnHighlightClickListener { + void onHighlightClick(final Story model, final int position); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/KeywordsFilterAdapter.java b/app/src/main/java/awais/instagrabber/adapters/KeywordsFilterAdapter.java new file mode 100644 index 0000000..d3b0cc7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/KeywordsFilterAdapter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.adapters; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.dialogs.KeywordsFilterDialogViewHolder; + +public class KeywordsFilterAdapter extends RecyclerView.Adapter { + + private final Context context; + private final ArrayList items; + + public KeywordsFilterAdapter(Context context, ArrayList items){ + this.context = context; + this.items = items; + } + + @NonNull + @Override + public KeywordsFilterDialogViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_keyword, parent, false); + return new KeywordsFilterDialogViewHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull KeywordsFilterDialogViewHolder holder, int position) { + holder.bind(items, position, context, this); + } + + @Override + public int getItemCount() { + return items.size(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/LikesAdapter.java b/app/src/main/java/awais/instagrabber/adapters/LikesAdapter.java new file mode 100755 index 0000000..aad654a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/LikesAdapter.java @@ -0,0 +1,44 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import awais.instagrabber.adapters.viewholder.FollowsViewHolder; +import awais.instagrabber.databinding.ItemFollowBinding; +import awais.instagrabber.repositories.responses.User; + +public final class LikesAdapter extends RecyclerView.Adapter { + private final List profileModels; + private final View.OnClickListener onClickListener; + + public LikesAdapter(final List profileModels, + final View.OnClickListener onClickListener) { + this.profileModels = profileModels; + this.onClickListener = onClickListener; + } + + @NonNull + @Override + public FollowsViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemFollowBinding binding = ItemFollowBinding.inflate(layoutInflater, parent, false); + return new FollowsViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final FollowsViewHolder holder, final int position) { + final User model = profileModels.get(position); + holder.bind(model, onClickListener); + } + + @Override + public int getItemCount() { + return profileModels.size(); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/NotificationsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/NotificationsAdapter.java new file mode 100644 index 0000000..55fd2b1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/NotificationsAdapter.java @@ -0,0 +1,97 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import awais.instagrabber.adapters.viewholder.NotificationViewHolder; +import awais.instagrabber.databinding.ItemNotificationBinding; +import awais.instagrabber.models.enums.NotificationType; +import awais.instagrabber.repositories.responses.notification.Notification; + +public final class NotificationsAdapter extends ListAdapter { + private final OnNotificationClickListener notificationClickListener; + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(final Notification oldItem, final Notification newItem) { + return oldItem.getPk().equals(newItem.getPk()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Notification oldItem, @NonNull final Notification newItem) { + return oldItem.getPk().equals(newItem.getPk()) && oldItem.getType() == newItem.getType(); + } + }; + + public NotificationsAdapter(final OnNotificationClickListener notificationClickListener) { + super(DIFF_CALLBACK); + this.notificationClickListener = notificationClickListener; + } + + @NonNull + @Override + public NotificationViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemNotificationBinding binding = ItemNotificationBinding.inflate(layoutInflater, parent, false); + return new NotificationViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final NotificationViewHolder holder, final int position) { + final Notification Notification = getItem(position); + holder.bind(Notification, notificationClickListener); + } + + @Override + public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { + if (list == null) { + super.submitList(null, commitCallback); + return; + } + super.submitList(sort(list), commitCallback); + } + + @Override + public void submitList(@Nullable final List list) { + if (list == null) { + super.submitList(null); + return; + } + super.submitList(sort(list)); + } + + private List sort(final List list) { + final List listCopy = new ArrayList<>(list).stream() + .filter(i -> i.getType() != null) + .collect(Collectors.toList()); + Collections.sort(listCopy, (o1, o2) -> { + // keep requests at top + if (o1.getType() == o2.getType() + && o1.getType() == NotificationType.REQUEST + && o2.getType() == NotificationType.REQUEST) return 0; + else if (o1.getType() == NotificationType.REQUEST) return -1; + else if (o2.getType() == NotificationType.REQUEST) return 1; + // timestamp + return Double.compare(o2.getArgs().getTimestamp(), o1.getArgs().getTimestamp()); + }); + return listCopy; + } + + public interface OnNotificationClickListener { + void onNotificationClick(final Notification model); + + void onProfileClick(final String username); + + void onPreviewClick(final Notification model); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/SavedCollectionsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SavedCollectionsAdapter.java new file mode 100644 index 0000000..68adce5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/SavedCollectionsAdapter.java @@ -0,0 +1,61 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.Objects; + +import awais.instagrabber.adapters.viewholder.TopicClusterViewHolder; +import awais.instagrabber.databinding.ItemDiscoverTopicBinding; +import awais.instagrabber.repositories.responses.saved.SavedCollection; + +public class SavedCollectionsAdapter extends ListAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { + return oldItem.getCollectionId().equals(newItem.getCollectionId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { + if (oldItem.getCoverMediaList() != null && newItem.getCoverMediaList() != null + && oldItem.getCoverMediaList().size() == newItem.getCoverMediaList().size()) { + return Objects.equals(oldItem.getCoverMediaList().get(0).getId(), newItem.getCoverMediaList().get(0).getId()); + } + else if (oldItem.getCoverMedia() != null && newItem.getCoverMedia() != null) { + return Objects.equals(oldItem.getCoverMedia().getId(), newItem.getCoverMedia().getId()); + } + return false; + } + }; + + private final OnCollectionClickListener onCollectionClickListener; + + public SavedCollectionsAdapter(final OnCollectionClickListener onCollectionClickListener) { + super(DIFF_CALLBACK); + this.onCollectionClickListener = onCollectionClickListener; + } + + @NonNull + @Override + public TopicClusterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemDiscoverTopicBinding binding = ItemDiscoverTopicBinding.inflate(layoutInflater, parent, false); + return new TopicClusterViewHolder(binding, null, onCollectionClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final TopicClusterViewHolder holder, final int position) { + final SavedCollection topicCluster = getItem(position); + holder.bind(topicCluster); + } + + public interface OnCollectionClickListener { + void onCollectionClick(SavedCollection savedCollection, View root, View cover, View title, int titleColor, int backgroundColor); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/SearchCategoryAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SearchCategoryAdapter.java new file mode 100644 index 0000000..9f6887f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/SearchCategoryAdapter.java @@ -0,0 +1,33 @@ +package awais.instagrabber.adapters; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import java.util.List; + +import awais.instagrabber.fragments.search.SearchCategoryFragment; +import awais.instagrabber.models.enums.FavoriteType; + +public class SearchCategoryAdapter extends FragmentStateAdapter { + + private final List categories; + + public SearchCategoryAdapter(@NonNull final Fragment fragment, + @NonNull final List categories) { + super(fragment); + this.categories = categories; + + } + + @NonNull + @Override + public Fragment createFragment(final int position) { + return SearchCategoryFragment.newInstance(categories.get(position)); + } + + @Override + public int getItemCount() { + return categories.size(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java new file mode 100644 index 0000000..e26059d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java @@ -0,0 +1,215 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.SearchItemViewHolder; +import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; +import awais.instagrabber.databinding.ItemSearchResultBinding; +import awais.instagrabber.fragments.search.SearchCategoryFragment.OnSearchItemClickListener; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; + +public final class SearchItemsAdapter extends RecyclerView.Adapter { + private static final String TAG = SearchItemsAdapter.class.getSimpleName(); + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final SearchItemOrHeader oldItem, @NonNull final SearchItemOrHeader newItem) { + return Objects.equals(oldItem, newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull final SearchItemOrHeader oldItem, @NonNull final SearchItemOrHeader newItem) { + return Objects.equals(oldItem, newItem); + } + }; + private static final String RECENT = "recent"; + private static final String FAVORITE = "favorite"; + private static final int VIEW_TYPE_HEADER = 0; + private static final int VIEW_TYPE_ITEM = 1; + + private final OnSearchItemClickListener onSearchItemClickListener; + private final AsyncListDiffer differ; + + public SearchItemsAdapter(final OnSearchItemClickListener onSearchItemClickListener) { + differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(DIFF_CALLBACK).build()); + this.onSearchItemClickListener = onSearchItemClickListener; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + if (viewType == VIEW_TYPE_HEADER) { + return new HeaderViewHolder(ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false)); + } + final ItemSearchResultBinding binding = ItemSearchResultBinding.inflate(layoutInflater, parent, false); + return new SearchItemViewHolder(binding, onSearchItemClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + if (getItemViewType(position) == VIEW_TYPE_HEADER) { + final SearchItemOrHeader searchItemOrHeader = getItem(position); + if (!searchItemOrHeader.isHeader()) return; + ((HeaderViewHolder) holder).bind(searchItemOrHeader.header); + return; + } + ((SearchItemViewHolder) holder).bind(getItem(position).searchItem); + } + + protected SearchItemOrHeader getItem(int position) { + return differ.getCurrentList().get(position); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public int getItemViewType(final int position) { + return getItem(position).isHeader() ? VIEW_TYPE_HEADER : VIEW_TYPE_ITEM; + } + + public void submitList(@Nullable final List list) { + if (list == null) { + differ.submitList(null); + return; + } + differ.submitList(sectionAndSort(list)); + } + + public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { + if (list == null) { + differ.submitList(null, commitCallback); + return; + } + differ.submitList(sectionAndSort(list), commitCallback); + } + + @NonNull + private List sectionAndSort(@NonNull final List list) { + final boolean containsRecentOrFavorite = list.stream().anyMatch(searchItem -> searchItem.isRecent() || searchItem.isFavorite()); + // Don't do anything if not showing recent results + if (!containsRecentOrFavorite) { + return list.stream() + .map(SearchItemOrHeader::new) + .collect(Collectors.toList()); + } + final List listCopy = new ArrayList<>(list); + Collections.sort(listCopy, (o1, o2) -> { + final boolean bothRecent = o1.isRecent() && o2.isRecent(); + if (bothRecent) { + // Don't sort + return 0; + } + final boolean bothFavorite = o1.isFavorite() && o2.isFavorite(); + if (bothFavorite) { + if (o1.getType() == o2.getType()) return 0; + // keep users at top + if (o1.getType() == FavoriteType.USER) return -1; + if (o2.getType() == FavoriteType.USER) return 1; + // keep locations at bottom + if (o1.getType() == FavoriteType.LOCATION) return 1; + if (o2.getType() == FavoriteType.LOCATION) return -1; + } + // keep recents at top + if (o1.isRecent()) return -1; + if (o2.isRecent()) return 1; + return 0; + }); + final List itemOrHeaders = new ArrayList<>(); + for (int i = 0; i < listCopy.size(); i++) { + final SearchItem searchItem = listCopy.get(i); + final SearchItemOrHeader prev = itemOrHeaders.isEmpty() ? null : itemOrHeaders.get(itemOrHeaders.size() - 1); + boolean prevWasSameType = prev != null && ((prev.searchItem.isRecent() && searchItem.isRecent()) + || (prev.searchItem.isFavorite() && searchItem.isFavorite())); + if (prevWasSameType) { + // just add the item + itemOrHeaders.add(new SearchItemOrHeader(searchItem)); + continue; + } + // add header and item + // add header only if search item is recent or favorite + if (searchItem.isRecent() || searchItem.isFavorite()) { + itemOrHeaders.add(new SearchItemOrHeader(searchItem.isRecent() ? RECENT : FAVORITE)); + } + itemOrHeaders.add(new SearchItemOrHeader(searchItem)); + } + return itemOrHeaders; + } + + private static class SearchItemOrHeader { + String header; + SearchItem searchItem; + + public SearchItemOrHeader(final SearchItem searchItem) { + this.searchItem = searchItem; + } + + public SearchItemOrHeader(final String header) { + this.header = header; + } + + boolean isHeader() { + return header != null; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SearchItemOrHeader that = (SearchItemOrHeader) o; + return Objects.equals(header, that.header) && + Objects.equals(searchItem, that.searchItem); + } + + @Override + public int hashCode() { + return Objects.hash(header, searchItem); + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + private final ItemFavSectionHeaderBinding binding; + + public HeaderViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final String header) { + if (header == null) return; + final int headerText; + switch (header) { + case RECENT: + headerText = R.string.recent; + break; + case FAVORITE: + headerText = R.string.title_favorites; + break; + default: + headerText = R.string.unknown; + break; + } + binding.getRoot().setText(headerText); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/SliderCallbackAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SliderCallbackAdapter.java new file mode 100644 index 0000000..a92192e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/SliderCallbackAdapter.java @@ -0,0 +1,32 @@ +package awais.instagrabber.adapters; + +import android.view.View; + +import com.google.android.exoplayer2.ui.StyledPlayerView; + +import awais.instagrabber.repositories.responses.Media; + +public class SliderCallbackAdapter implements SliderItemsAdapter.SliderCallback { + @Override + public void onThumbnailLoaded(final int position) {} + + @Override + public void onItemClicked(final int position, final Media media, final View view) {} + + @Override + public void onPlayerPlay(final int position) {} + + @Override + public void onPlayerPause(final int position) {} + + @Override + public void onPlayerRelease(final int position) {} + + @Override + public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) {} + + @Override + public boolean isInFullScreen() { + return false; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/SliderItemsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SliderItemsAdapter.java new file mode 100644 index 0000000..cd851a2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/SliderItemsAdapter.java @@ -0,0 +1,153 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import com.google.android.exoplayer2.ui.StyledPlayerView; + +import awais.instagrabber.adapters.viewholder.SliderItemViewHolder; +import awais.instagrabber.adapters.viewholder.SliderPhotoViewHolder; +import awais.instagrabber.adapters.viewholder.SliderVideoViewHolder; +import awais.instagrabber.databinding.ItemSliderPhotoBinding; +import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; + +public final class SliderItemsAdapter extends ListAdapter { + + private final boolean loadVideoOnItemClick; + private final SliderCallback sliderCallback; + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { + return oldItem.getPk().equals(newItem.getPk()); + } + + @Override + public boolean areContentsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { + return oldItem.getPk().equals(newItem.getPk()); + } + }; + + public SliderItemsAdapter(final boolean loadVideoOnItemClick, + final SliderCallback sliderCallback) { + super(DIFF_CALLBACK); + this.loadVideoOnItemClick = loadVideoOnItemClick; + this.sliderCallback = sliderCallback; + } + + @NonNull + @Override + public SliderItemViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final MediaItemType mediaItemType = MediaItemType.valueOf(viewType); + switch (mediaItemType) { + case MEDIA_TYPE_VIDEO: { + final LayoutVideoPlayerWithThumbnailBinding binding = LayoutVideoPlayerWithThumbnailBinding.inflate(inflater, parent, false); + return new SliderVideoViewHolder(binding, loadVideoOnItemClick); + } + case MEDIA_TYPE_IMAGE: + default: + final ItemSliderPhotoBinding binding = ItemSliderPhotoBinding.inflate(inflater, parent, false); + return new SliderPhotoViewHolder(binding); + } + } + + @Override + public void onBindViewHolder(@NonNull final SliderItemViewHolder holder, final int position) { + final Media media = getItem(position); + holder.bind(media, position, sliderCallback); + } + + @Override + public int getItemViewType(final int position) { + final Media media = getItem(position); + return media.getType().getId(); + } + + // @NonNull + // @Override + // public Object instantiateItem(@NonNull final ViewGroup container, final int position) { + // final Context context = container.getContext(); + // final ViewerPostModel sliderItem = sliderItems.get(position); + // + // if (sliderItem.getItemType() == MediaItemType.MEDIA_TYPE_VIDEO) { + // final ViewSwitcher viewSwitcher = createViewSwitcher(context, position, sliderItem.getThumbnailUrl(), sliderItem.getDisplayUrl()); + // container.addView(viewSwitcher); + // return viewSwitcher; + // } + // final GenericDraweeHierarchy hierarchy = GenericDraweeHierarchyBuilder.newInstance(container.getResources()) + // .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + // .build(); + // final SimpleDraweeView photoView = new SimpleDraweeView(context, hierarchy); + // photoView.setLayoutParams(layoutParams); + // final ImageRequest imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(sliderItem.getDisplayUrl())) + // .setLocalThumbnailPreviewsEnabled(true) + // .setProgressiveRenderingEnabled(true) + // .build(); + // photoView.setImageRequest(imageRequest); + // container.addView(photoView); + // return photoView; + // } + + // @NonNull + // private ViewSwitcher createViewSwitcher(final Context context, + // final int position, + // final String thumbnailUrl, + // final String displayUrl) { + // + // final ViewSwitcher viewSwitcher = new ViewSwitcher(context); + // viewSwitcher.setLayoutParams(layoutParams); + // + // final FrameLayout frameLayout = new FrameLayout(context); + // frameLayout.setLayoutParams(layoutParams); + // + // final GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(context.getResources()) + // .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + // .build(); + // final SimpleDraweeView simpleDraweeView = new SimpleDraweeView(context, hierarchy); + // simpleDraweeView.setLayoutParams(layoutParams); + // simpleDraweeView.setImageURI(thumbnailUrl); + // frameLayout.addView(simpleDraweeView); + // + // final AppCompatImageView imageView = new AppCompatImageView(context); + // final int px = Utils.convertDpToPx(50); + // final FrameLayout.LayoutParams playButtonLayoutParams = new FrameLayout.LayoutParams(px, px); + // playButtonLayoutParams.gravity = Gravity.CENTER; + // imageView.setLayoutParams(playButtonLayoutParams); + // imageView.setImageResource(R.drawable.exo_icon_play); + // frameLayout.addView(imageView); + // + // viewSwitcher.addView(frameLayout); + // + // final PlayerView playerView = new PlayerView(context); + // viewSwitcher.addView(playerView); + // if (shouldAutoPlay && position == 0) { + // loadPlayer(context, position, displayUrl, viewSwitcher, factory, playerChangeListener); + // } else + // frameLayout.setOnClickListener(v -> loadPlayer(context, position, displayUrl, viewSwitcher, factory, playerChangeListener)); + // return viewSwitcher; + // } + + public interface SliderCallback { + void onThumbnailLoaded(int position); + + void onItemClicked(int position, final Media media, final View view); + + void onPlayerPlay(int position); + + void onPlayerPause(int position); + + void onPlayerRelease(int position); + + void onFullScreenModeChanged(boolean isFullScreen, final StyledPlayerView playerView); + + boolean isInFullScreen(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/StoriesAdapter.java b/app/src/main/java/awais/instagrabber/adapters/StoriesAdapter.java new file mode 100755 index 0000000..21a5b62 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/StoriesAdapter.java @@ -0,0 +1,90 @@ +package awais.instagrabber.adapters; + +import java.util.List; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.databinding.ItemStoryBinding; +import awais.instagrabber.repositories.responses.stories.StoryMedia; +import awais.instagrabber.utils.ResponseBodyUtils; + +public final class StoriesAdapter extends ListAdapter { + private final OnItemClickListener onItemClickListener; + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final StoryMedia oldItem, @NonNull final StoryMedia newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final StoryMedia oldItem, @NonNull final StoryMedia newItem) { + return oldItem.getId().equals(newItem.getId()); + } + }; + + public StoriesAdapter(final OnItemClickListener onItemClickListener) { + super(diffCallback); + this.onItemClickListener = onItemClickListener; + } + + @NonNull + @Override + public StoryViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemStoryBinding binding = ItemStoryBinding.inflate(layoutInflater, parent, false); + return new StoryViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull final StoryViewHolder holder, final int position) { + final StoryMedia storyMedia = getItem(position); + holder.bind(storyMedia, position, onItemClickListener); + } + + public final static class StoryViewHolder extends RecyclerView.ViewHolder { + private final ItemStoryBinding binding; + + public StoryViewHolder(final ItemStoryBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final StoryMedia model, + final int position, + final OnItemClickListener clickListener) { + if (model == null) return; + model.setPosition(position); + + itemView.setTag(model); + itemView.setOnClickListener(v -> { + if (clickListener == null) return; + clickListener.onItemClick(model, position); + }); + + binding.selectedView.setVisibility(model.isCurrentSlide() ? View.VISIBLE : View.GONE); + binding.icon.setImageURI(ResponseBodyUtils.getThumbUrl(model)); + } + } + + public void paginate(final int newIndex) { + final List list = getCurrentList(); + for (int i = 0; i < list.size(); i++) { + final StoryMedia item = list.get(i); + if (!item.isCurrentSlide() && i != newIndex) continue; + item.setCurrentSlide(i == newIndex); + notifyItemChanged(i, item); + } + } + + public interface OnItemClickListener { + void onItemClick(StoryMedia storyModel, int position); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/TabsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/TabsAdapter.java new file mode 100644 index 0000000..474e419 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/TabsAdapter.java @@ -0,0 +1,156 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.TabViewHolder; +import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; +import awais.instagrabber.databinding.ItemTabOrderPrefBinding; +import awais.instagrabber.models.Tab; +import awais.instagrabber.utils.Utils; + +public class TabsAdapter extends ListAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final TabOrHeader oldItem, @NonNull final TabOrHeader newItem) { + if (oldItem.isHeader() && newItem.isHeader()) { + return oldItem.header == newItem.header; + } + if (!oldItem.isHeader() && !newItem.isHeader()) { + final Tab oldTab = oldItem.tab; + final Tab newTab = newItem.tab; + return oldTab.getIconResId() == newTab.getIconResId() + && Objects.equals(oldTab.getTitle(), newTab.getTitle()); + } + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull final TabOrHeader oldItem, @NonNull final TabOrHeader newItem) { + if (oldItem.isHeader() && newItem.isHeader()) { + return oldItem.header == newItem.header; + } + if (!oldItem.isHeader() && !newItem.isHeader()) { + final Tab oldTab = oldItem.tab; + final Tab newTab = newItem.tab; + return oldTab.getIconResId() == newTab.getIconResId() + && Objects.equals(oldTab.getTitle(), newTab.getTitle()); + } + return false; + } + }; + + private final TabAdapterCallback tabAdapterCallback; + + private List current = new ArrayList<>(); + private List others = new ArrayList<>(); + + public TabsAdapter(@NonNull final TabAdapterCallback tabAdapterCallback) { + super(DIFF_CALLBACK); + this.tabAdapterCallback = tabAdapterCallback; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + if (viewType == 1) { + final ItemTabOrderPrefBinding binding = ItemTabOrderPrefBinding.inflate(layoutInflater, parent, false); + return new TabViewHolder(binding, tabAdapterCallback); + } + final ItemFavSectionHeaderBinding headerBinding = ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false); + return new DirectUsersAdapter.HeaderViewHolder(headerBinding); + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + if (holder instanceof DirectUsersAdapter.HeaderViewHolder) { + ((DirectUsersAdapter.HeaderViewHolder) holder).bind(R.string.other_tabs); + return; + } + if (holder instanceof TabViewHolder) { + final Tab tab = getItem(position).tab; + ((TabViewHolder) holder).bind(tab, others.contains(tab), current.size() == 5); + } + } + + @Override + public int getItemViewType(final int position) { + return getItem(position).isHeader() ? 0 : 1; + } + + public void submitList(final List current, final List others, final Runnable commitCallback) { + final ImmutableList.Builder builder = ImmutableList.builder(); + if (current != null) { + builder.addAll(current.stream() + .map(TabOrHeader::new) + .collect(Collectors.toList())); + } + builder.add(new TabOrHeader(R.string.other_tabs)); + if (others != null) { + builder.addAll(others.stream() + .map(TabOrHeader::new) + .collect(Collectors.toList())); + } + // Mutable non-null copies + this.current = current != null ? new ArrayList<>(current) : new ArrayList<>(); + this.others = others != null ? new ArrayList<>(others) : new ArrayList<>(); + submitList(builder.build(), commitCallback); + } + + public void submitList(final List current, final List others) { + submitList(current, others, null); + } + + public void moveItem(final int from, final int to) { + final List currentCopy = new ArrayList<>(current); + Utils.moveItem(from, to, currentCopy); + submitList(currentCopy, others); + tabAdapterCallback.onOrderChange(currentCopy); + } + + public int getCurrentCount() { + return current.size(); + } + + public static class TabOrHeader { + Tab tab; + int header; + + public TabOrHeader(final Tab tab) { + this.tab = tab; + } + + public TabOrHeader(@StringRes final int header) { + this.header = header; + } + + boolean isHeader() { + return header != 0; + } + } + + public interface TabAdapterCallback { + void onStartDrag(TabViewHolder viewHolder); + + void onOrderChange(List newOrderTabs); + + void onAdd(Tab tab); + + void onRemove(Tab tab); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java new file mode 100644 index 0000000..5a825e1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java @@ -0,0 +1,151 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; +import awais.instagrabber.adapters.viewholder.directmessages.DirectUserViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.RecipientThreadViewHolder; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; + +public final class UserSearchResultsAdapter extends ListAdapter { + private static final int VIEW_TYPE_USER = 0; + private static final int VIEW_TYPE_THREAD = 1; + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final RankedRecipient oldItem, @NonNull final RankedRecipient newItem) { + final boolean bothUsers = oldItem.getUser() != null && newItem.getUser() != null; + if (!bothUsers) return false; + final boolean bothThreads = oldItem.getThread() != null && newItem.getThread() != null; + if (!bothThreads) return false; + if (bothUsers) { + return oldItem.getUser().getPk() == newItem.getUser().getPk(); + } + return Objects.equals(oldItem.getThread().getThreadId(), newItem.getThread().getThreadId()); + } + + @Override + public boolean areContentsTheSame(@NonNull final RankedRecipient oldItem, @NonNull final RankedRecipient newItem) { + final boolean bothUsers = oldItem.getUser() != null && newItem.getUser() != null; + if (bothUsers) { + return Objects.equals(oldItem.getUser().getUsername(), newItem.getUser().getUsername()) && + Objects.equals(oldItem.getUser().getFullName(), newItem.getUser().getFullName()); + } + return Objects.equals(oldItem.getThread().getThreadTitle(), newItem.getThread().getThreadTitle()); + } + }; + + private final boolean showSelection; + private final Set selectedRecipients; + private final OnDirectUserClickListener onUserClickListener; + private final OnRecipientClickListener onRecipientClickListener; + + public UserSearchResultsAdapter(final boolean showSelection, + final OnRecipientClickListener onRecipientClickListener) { + super(DIFF_CALLBACK); + this.showSelection = showSelection; + selectedRecipients = showSelection ? new HashSet<>() : null; + this.onRecipientClickListener = onRecipientClickListener; + this.onUserClickListener = (position, user, selected) -> { + if (onRecipientClickListener != null) { + onRecipientClickListener.onClick(position, RankedRecipient.of(user), selected); + } + }; + setHasStableIds(true); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); + if (viewType == VIEW_TYPE_USER) { + return new DirectUserViewHolder(binding, onUserClickListener, null); + } + return new RecipientThreadViewHolder(binding, onRecipientClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + final RankedRecipient recipient = getItem(position); + final int itemViewType = getItemViewType(position); + if (itemViewType == VIEW_TYPE_USER) { + boolean isSelected = false; + if (selectedRecipients != null) { + isSelected = selectedRecipients.stream() + .anyMatch(rankedRecipient -> rankedRecipient.getUser() != null + && rankedRecipient.getUser().getPk() == recipient.getUser().getPk()); + } + ((DirectUserViewHolder) holder).bind(position, recipient.getUser(), false, false, showSelection, isSelected); + return; + } + boolean isSelected = false; + if (selectedRecipients != null) { + isSelected = selectedRecipients.stream() + .anyMatch(rankedRecipient -> rankedRecipient.getThread() != null + && Objects.equals(rankedRecipient.getThread().getThreadId(), recipient.getThread().getThreadId())); + } + ((RecipientThreadViewHolder) holder).bind(position, recipient.getThread(), showSelection, isSelected); + } + + @Override + public long getItemId(final int position) { + final RankedRecipient recipient = getItem(position); + if (recipient.getUser() != null) { + return recipient.getUser().getPk(); + } + if (recipient.getThread() != null) { + return recipient.getThread().getThreadTitle().hashCode(); + } + return 0; + } + + @Override + public int getItemViewType(final int position) { + final RankedRecipient recipient = getItem(position); + return recipient.getUser() != null ? VIEW_TYPE_USER : VIEW_TYPE_THREAD; + } + + public void setSelectedRecipient(final RankedRecipient recipient, final boolean selected) { + if (selectedRecipients == null || recipient == null || (recipient.getUser() == null && recipient.getThread() == null)) return; + final boolean isUser = recipient.getUser() != null; + int position = -1; + final List currentList = getCurrentList(); + for (int i = 0; i < currentList.size(); i++) { + final RankedRecipient temp = currentList.get(i); + if (isUser) { + if (temp.getUser() != null && temp.getUser().getPk() == recipient.getUser().getPk()) { + position = i; + break; + } + continue; + } + if (temp.getThread() != null && Objects.equals(temp.getThread().getThreadId(), recipient.getThread().getThreadId())) { + position = i; + break; + } + } + if (position < 0) return; + if (selected) { + selectedRecipients.add(recipient); + } else { + selectedRecipients.remove(recipient); + } + notifyItemChanged(position); + } + + public interface OnRecipientClickListener { + void onClick(int position, RankedRecipient recipient, final boolean isSelected); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/CommentViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/CommentViewHolder.java new file mode 100644 index 0000000..bb1d3e0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/CommentViewHolder.java @@ -0,0 +1,207 @@ +package awais.instagrabber.adapters.viewholder; + +import android.content.Context; +import android.content.res.Resources; +import android.util.TypedValue; +import android.view.Menu; +import android.view.View; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.CommentsAdapter.CommentCallback; +import awais.instagrabber.customviews.ProfilePicView; +import awais.instagrabber.databinding.ItemCommentBinding; +import awais.instagrabber.models.Comment; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.Utils; + +public final class CommentViewHolder extends RecyclerView.ViewHolder { + + private final ItemCommentBinding binding; + private final long currentUserId; + private final CommentCallback commentCallback; + @ColorInt + private int parentCommentHighlightColor; + private PopupMenu optionsPopup; + + public CommentViewHolder(@NonNull final ItemCommentBinding binding, + final long currentUserId, + final CommentCallback commentCallback) { + super(binding.getRoot()); + this.binding = binding; + this.currentUserId = currentUserId; + this.commentCallback = commentCallback; + final Context context = itemView.getContext(); + if (context == null) return; + final Resources.Theme theme = context.getTheme(); + if (theme == null) return; + final TypedValue typedValue = new TypedValue(); + final boolean resolved = theme.resolveAttribute(R.attr.parentCommentHighlightColor, typedValue, true); + if (resolved) { + parentCommentHighlightColor = typedValue.data; + } + } + + public void bind(final Comment comment, final boolean isReplyParent, final boolean isReply) { + if (comment == null) return; + itemView.setOnClickListener(v -> { + if (commentCallback != null) { + commentCallback.onClick(comment); + } + }); + if (isReplyParent && parentCommentHighlightColor != 0) { + itemView.setBackgroundColor(parentCommentHighlightColor); + } else { + itemView.setBackgroundColor(itemView.getResources().getColor(android.R.color.transparent)); + } + setupCommentText(comment, isReply); + binding.date.setText(comment.getDateTime()); + setLikes(comment, isReply); + setReplies(comment, isReply); + setUser(comment, isReply); + setupOptions(comment, isReply); + } + + private void setupCommentText(@NonNull final Comment comment, final boolean isReply) { + binding.comment.clearOnURLClickListeners(); + binding.comment.clearOnHashtagClickListeners(); + binding.comment.clearOnMentionClickListeners(); + binding.comment.clearOnEmailClickListeners(); + binding.comment.setText(comment.getText()); + binding.comment.setTextSize(TypedValue.COMPLEX_UNIT_SP, isReply ? 12 : 14); + binding.comment.addOnHashtagListener(autoLinkItem -> { + final String originalText = autoLinkItem.getOriginalText(); + if (commentCallback == null) return; + commentCallback.onHashtagClick(originalText); + }); + binding.comment.addOnMentionClickListener(autoLinkItem -> { + final String originalText = autoLinkItem.getOriginalText(); + if (commentCallback == null) return; + commentCallback.onMentionClick(originalText); + + }); + binding.comment.addOnEmailClickListener(autoLinkItem -> { + final String originalText = autoLinkItem.getOriginalText(); + if (commentCallback == null) return; + commentCallback.onEmailClick(originalText); + }); + binding.comment.addOnURLClickListener(autoLinkItem -> { + final String originalText = autoLinkItem.getOriginalText(); + if (commentCallback == null) return; + commentCallback.onURLClick(originalText); + }); + binding.comment.setOnLongClickListener(v -> { + Utils.copyText(itemView.getContext(), comment.getText()); + return true; + }); + binding.comment.setOnClickListener(v -> commentCallback.onClick(comment)); + } + + private void setUser(@NonNull final Comment comment, final boolean isReply) { + final User user = comment.getUser(); + if (user == null) return; + binding.username.setUsername(user.getUsername(), user.isVerified()); + binding.username.setTextAppearance(itemView.getContext(), isReply ? R.style.TextAppearance_MaterialComponents_Subtitle2 + : R.style.TextAppearance_MaterialComponents_Subtitle1); + binding.username.setOnClickListener(v -> { + if (commentCallback == null) return; + commentCallback.onMentionClick("@" + user.getUsername()); + }); + binding.profilePic.setImageURI(user.getProfilePicUrl()); + binding.profilePic.setSize(isReply ? ProfilePicView.Size.SMALLER : ProfilePicView.Size.SMALL); + binding.profilePic.setOnClickListener(v -> { + if (commentCallback == null) return; + commentCallback.onMentionClick("@" + user.getUsername()); + }); + } + + private void setLikes(@NonNull final Comment comment, final boolean isReply) { + binding.likes.setText(String.valueOf(comment.getCommentLikeCount())); + binding.likes.setOnLongClickListener(v -> { + if (commentCallback == null) return false; + commentCallback.onViewLikes(comment); + return true; + }); + if (currentUserId == 0) { // not logged in + binding.likes.setOnClickListener(v -> { + if (commentCallback == null) return; + commentCallback.onViewLikes(comment); + }); + return; + } + final boolean liked = comment.getLiked(); + final int resId = liked ? R.drawable.ic_like : R.drawable.ic_not_liked; + binding.likes.setCompoundDrawablesRelativeWithSize(ContextCompat.getDrawable(itemView.getContext(), resId), null, null, null); + binding.likes.setOnClickListener(v -> { + if (commentCallback == null) return; + // toggle like + commentCallback.onLikeClick(comment, !liked, isReply); + }); + } + + private void setReplies(@NonNull final Comment comment, final boolean isReply) { + final int replies = comment.getChildCommentCount(); + binding.replies.setVisibility(View.VISIBLE); + final String text = isReply ? "" : String.valueOf(replies); + binding.replies.setText(text); + binding.replies.setOnClickListener(v -> { + if (commentCallback == null) return; + commentCallback.onRepliesClick(comment); + }); + } + + private void setupOptions(final Comment comment, final boolean isReply) { + binding.options.setOnClickListener(v -> { + if (optionsPopup == null) { + createOptionsPopupMenu(comment, isReply); + } + if (optionsPopup == null) return; + optionsPopup.show(); + }); + } + + private void createOptionsPopupMenu(final Comment comment, final boolean isReply) { + if (optionsPopup == null) { + final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(itemView.getContext(), R.style.popupMenuStyle); + optionsPopup = new PopupMenu(themeWrapper, binding.options); + } else { + optionsPopup.getMenu().clear(); + } + optionsPopup.getMenuInflater().inflate(R.menu.comment_options_menu, optionsPopup.getMenu()); + final User user = comment.getUser(); + if (currentUserId == 0 || user == null || user.getPk() != currentUserId) { + final Menu menu = optionsPopup.getMenu(); + menu.removeItem(R.id.delete); + } + optionsPopup.setOnMenuItemClickListener(item -> { + if (commentCallback == null) return false; + int itemId = item.getItemId(); + if (itemId == R.id.translate) { + commentCallback.onTranslate(comment); + return true; + } + if (itemId == R.id.delete) { + commentCallback.onDelete(comment, isReply); + } + return true; + }); + } + + // private void setupReply(final Comment comment) { + // if (!isLoggedIn) { + // binding.reply.setVisibility(View.GONE); + // return; + // } + // binding.reply.setOnClickListener(v -> { + // if (commentCallback == null) return; + // // toggle like + // commentCallback.onReplyClick(comment); + // }); + // } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/DiscoverViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/DiscoverViewHolder.java new file mode 100755 index 0000000..9473548 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/DiscoverViewHolder.java @@ -0,0 +1,26 @@ +//package awais.instagrabber.adapters.viewholder; +// +//import android.view.View; +//import android.widget.ImageView; +// +//import androidx.annotation.NonNull; +//import androidx.recyclerview.widget.RecyclerView; +// +//import com.facebook.drawee.view.SimpleDraweeView; +// +//import awais.instagrabber.R; +// +//public final class DiscoverViewHolder extends RecyclerView.ViewHolder { +// public final SimpleDraweeView postImage; +// public final ImageView typeIcon; +// public final View selectedView; +// // public final View progressView; +// +// public DiscoverViewHolder(@NonNull final View itemView) { +// super(itemView); +// typeIcon = itemView.findViewById(R.id.typeIcon); +// postImage = itemView.findViewById(R.id.postImage); +// selectedView = itemView.findViewById(R.id.selectedView); +// // progressView = itemView.findViewById(R.id.progressView); +// } +//} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java new file mode 100644 index 0000000..bf9da89 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java @@ -0,0 +1,61 @@ +package awais.instagrabber.adapters.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.adapters.FavoritesAdapter; +import awais.instagrabber.databinding.ItemSearchResultBinding; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.utils.Constants; + +public class FavoriteViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = "FavoriteViewHolder"; + + private final ItemSearchResultBinding binding; + + public FavoriteViewHolder(@NonNull final ItemSearchResultBinding binding) { + super(binding.getRoot()); + this.binding = binding; + binding.verified.setVisibility(View.GONE); + } + + public void bind(final Favorite model, + final FavoritesAdapter.OnFavoriteClickListener clickListener, + final FavoritesAdapter.OnFavoriteLongClickListener longClickListener) { + // Log.d(TAG, "bind: " + model); + if (model == null) return; + itemView.setOnClickListener(v -> { + if (clickListener == null) return; + clickListener.onClick(model); + }); + itemView.setOnLongClickListener(v -> { + if (clickListener == null) return false; + return longClickListener.onLongClick(model); + }); + if (model.getType() == FavoriteType.HASHTAG) { + binding.profilePic.setImageURI(Constants.DEFAULT_HASH_TAG_PIC); + } else { + binding.profilePic.setImageURI(model.getPicUrl()); + } + binding.title.setVisibility(View.VISIBLE); + binding.subtitle.setText(model.getDisplayName()); + String query = model.getQuery(); + switch (model.getType()) { + case HASHTAG: + query = "#" + query; + break; + case USER: + query = "@" + query; + break; + case LOCATION: + binding.title.setVisibility(View.GONE); + break; + default: + // do nothing + } + binding.title.setText(query); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/FeedGridItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/FeedGridItemViewHolder.java new file mode 100644 index 0000000..9f7718a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/FeedGridItemViewHolder.java @@ -0,0 +1,212 @@ +package awais.instagrabber.adapters.viewholder; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.net.Uri; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder; +import com.facebook.imagepipeline.common.ResizeOptions; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.databinding.ItemFeedGridBinding; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; + +import static awais.instagrabber.models.PostsLayoutPreferences.PostsLayoutType.STAGGERED_GRID; + +public class FeedGridItemViewHolder extends RecyclerView.ViewHolder { + private final ItemFeedGridBinding binding; + + public FeedGridItemViewHolder(@NonNull final ItemFeedGridBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final int position, + @NonNull final Media media, + @NonNull final PostsLayoutPreferences layoutPreferences, + final FeedAdapterV2.FeedItemCallback feedItemCallback, + final FeedAdapterV2.AdapterSelectionCallback adapterSelectionCallback, + final boolean selectionModeActive, + final boolean selected) { + itemView.setOnClickListener(v -> { + if (!selectionModeActive && feedItemCallback != null) { + feedItemCallback.onPostClick(media); + return; + } + if (selectionModeActive && adapterSelectionCallback != null) { + adapterSelectionCallback.onPostClick(position, media); + } + }); + if (adapterSelectionCallback != null) { + itemView.setOnLongClickListener(v -> adapterSelectionCallback.onPostLongClick(position, media)); + } + binding.selectedView.setVisibility(selected ? View.VISIBLE : View.GONE); + // for rounded borders (clip view to background shape) + itemView.setClipToOutline(layoutPreferences.getHasRoundedCorners()); + if (layoutPreferences.getType() == STAGGERED_GRID) { + final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); + binding.postImage.setAspectRatio(aspectRatio); + } else { + binding.postImage.setAspectRatio(1); + } + setUserDetails(media, layoutPreferences); + String thumbnailUrl = null; + final int typeIconRes; + final MediaItemType mediaType = media.getType(); + if (mediaType == null) return; + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + typeIconRes = -1; + thumbnailUrl = ResponseBodyUtils.getThumbUrl(media); + break; + case MEDIA_TYPE_VIDEO: + thumbnailUrl = ResponseBodyUtils.getThumbUrl(media); + typeIconRes = R.drawable.exo_icon_play; + break; + case MEDIA_TYPE_SLIDER: + final List sliderItems = media.getCarouselMedia(); + if (sliderItems != null) { + final Media child = sliderItems.get(0); + if (child != null) { + thumbnailUrl = ResponseBodyUtils.getThumbUrl(child); + if (layoutPreferences.getType() == STAGGERED_GRID) { + final float childAspectRatio = (float) child.getOriginalWidth() / child.getOriginalHeight(); + binding.postImage.setAspectRatio(childAspectRatio); + } + } + } + typeIconRes = R.drawable.ic_checkbox_multiple_blank_stroke; + break; + default: + typeIconRes = -1; + thumbnailUrl = null; + } + setThumbImage(thumbnailUrl); + if (typeIconRes <= 0) { + binding.typeIcon.setVisibility(View.GONE); + } else { + binding.typeIcon.setVisibility(View.VISIBLE); + binding.typeIcon.setImageResource(typeIconRes); + } + binding.downloaded.setVisibility(View.GONE); + final Context context = itemView.getContext(); + if (context == null) { + return; + } + AppExecutors.INSTANCE.getTasksThread().execute(() -> { + final List checkList = DownloadUtils.checkDownloaded(media, context); + if (checkList.isEmpty()) { + return; + } + AppExecutors.INSTANCE.getMainThread().execute(() -> { + switch (media.getType()) { + case MEDIA_TYPE_IMAGE: + case MEDIA_TYPE_VIDEO: + binding.downloaded.setVisibility(checkList.get(0) ? View.VISIBLE : View.GONE); + binding.downloaded.setImageTintList(ColorStateList.valueOf(itemView.getResources().getColor(R.color.green_A400))); + break; + case MEDIA_TYPE_SLIDER: + binding.downloaded.setVisibility(checkList.get(0) ? View.VISIBLE : View.GONE); + final List carouselMedia = media.getCarouselMedia(); + boolean allDownloaded = checkList.size() == (carouselMedia == null ? 0 : carouselMedia.size()); + if (allDownloaded) { + allDownloaded = checkList.stream().allMatch(downloaded -> downloaded); + } + binding.downloaded.setImageTintList(ColorStateList.valueOf(itemView.getResources().getColor( + allDownloaded ? R.color.green_A400 : R.color.yellow_400))); + break; + default: + } + }); + }); + } + + private void setThumbImage(final String thumbnailUrl) { + if (TextUtils.isEmpty(thumbnailUrl)) { + binding.postImage.setController(null); + return; + } + final ImageRequest requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(thumbnailUrl)) + .setResizeOptions(ResizeOptions.forDimensions(binding.postImage.getWidth(), + binding.postImage.getHeight())) + .setLocalThumbnailPreviewsEnabled(true) + .setProgressiveRenderingEnabled(true) + .build(); + final PipelineDraweeControllerBuilder builder = Fresco.newDraweeControllerBuilder() + .setImageRequest(requestBuilder) + .setOldController(binding.postImage.getController()); + binding.postImage.setController(builder.build()); + } + + private void setUserDetails(@NonNull final Media media, + @NonNull final PostsLayoutPreferences layoutPreferences) { + final User user = media.getUser(); + if (layoutPreferences.isAvatarVisible()) { + if (user == null) { + binding.profilePic.setVisibility(View.GONE); + } else { + final String profilePicUrl = user.getProfilePicUrl(); + if (TextUtils.isEmpty(profilePicUrl)) { + binding.profilePic.setVisibility(View.GONE); + } else { + binding.profilePic.setVisibility(View.VISIBLE); + binding.profilePic.setImageURI(profilePicUrl); + } + } + final ViewGroup.LayoutParams layoutParams = binding.profilePic.getLayoutParams(); + @DimenRes final int dimenRes; + switch (layoutPreferences.getProfilePicSize()) { + case SMALL: + dimenRes = R.dimen.profile_pic_size_small; + break; + case TINY: + dimenRes = R.dimen.profile_pic_size_tiny; + break; + default: + case REGULAR: + dimenRes = R.dimen.profile_pic_size_regular; + break; + } + final int dimensionPixelSize = itemView.getResources().getDimensionPixelSize(dimenRes); + layoutParams.width = dimensionPixelSize; + layoutParams.height = dimensionPixelSize; + binding.profilePic.requestLayout(); + } else { + binding.profilePic.setVisibility(View.GONE); + } + if (layoutPreferences.isNameVisible()) { + if (user == null) { + binding.name.setVisibility(View.GONE); + } else { + final String username = user.getUsername(); + if (username == null) { + binding.name.setVisibility(View.GONE); + } else { + binding.name.setVisibility(View.VISIBLE); + binding.name.setText(username); + } + } + } else { + binding.name.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/FeedStoryViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/FeedStoryViewHolder.java new file mode 100644 index 0000000..3e86276 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/FeedStoryViewHolder.java @@ -0,0 +1,44 @@ +package awais.instagrabber.adapters.viewholder; + +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.adapters.FeedStoriesAdapter; +import awais.instagrabber.databinding.ItemHighlightBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.stories.Story; + +public final class FeedStoryViewHolder extends RecyclerView.ViewHolder { + + private final ItemHighlightBinding binding; + + public FeedStoryViewHolder(final ItemHighlightBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final Story model, + final int position, + final FeedStoriesAdapter.OnFeedStoryClickListener listener) { + if (model == null) return; + binding.getRoot().setOnClickListener(v -> { + if (listener == null) return; + listener.onFeedStoryClick(model, position); + }); + binding.getRoot().setOnLongClickListener(v -> { + if (listener != null) listener.onFeedStoryLongClick(model, position); + return true; + }); + final User profileModel = model.getUser(); + binding.title.setText(profileModel.getUsername()); + final boolean isFullyRead = + model.getSeen() != null && + model.getSeen().equals(model.getLatestReelMedia()); + binding.title.setAlpha(isFullyRead ? 0.5F : 1.0F); + binding.icon.setImageURI(profileModel.getProfilePicUrl()); + binding.icon.setAlpha(isFullyRead ? 0.5F : 1.0F); + + if (model.getBroadcast() != null) binding.icon.setStoriesBorder(2); + else if (model.getHasBestiesMedia()) binding.icon.setStoriesBorder(1); + else binding.icon.setStoriesBorder(0); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/FilterViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/FilterViewHolder.java new file mode 100644 index 0000000..b26ddad --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/FilterViewHolder.java @@ -0,0 +1,72 @@ +package awais.instagrabber.adapters.viewholder; + +import android.graphics.Bitmap; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.util.Collection; + +import awais.instagrabber.adapters.FiltersAdapter; +import awais.instagrabber.databinding.ItemFilterBinding; +import awais.instagrabber.fragments.imageedit.filters.filters.Filter; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.BitmapUtils; +import jp.co.cyberagent.android.gpuimage.GPUImage; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; + +public class FilterViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = FilterViewHolder.class.getSimpleName(); + + private final ItemFilterBinding binding; + private final Collection tuneFilters; + private final FiltersAdapter.OnFilterClickListener onFilterClickListener; + private final AppExecutors appExecutors; + + public FilterViewHolder(@NonNull final ItemFilterBinding binding, + final Collection tuneFilters, + final FiltersAdapter.OnFilterClickListener onFilterClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.tuneFilters = tuneFilters; + this.onFilterClickListener = onFilterClickListener; + appExecutors = AppExecutors.INSTANCE; + } + + public void bind(final int position, final String originalKey, final Bitmap originalBitmap, final Filter item, final boolean isSelected) { + if (originalBitmap == null || item == null) return; + if (onFilterClickListener != null) { + itemView.setOnClickListener(v -> onFilterClickListener.onClick(position, item)); + } + if (item.getLabel() != -1) { + binding.name.setVisibility(View.VISIBLE); + binding.name.setText(item.getLabel()); + binding.name.setSelected(isSelected); + } else { + binding.name.setVisibility(View.GONE); + } + final String filterKey = item.getLabel() + "_" + originalKey; + // avoid resetting the bitmap + if (binding.preview.getTag() != null && binding.preview.getTag().equals(filterKey)) return; + binding.preview.setTag(filterKey); + final Bitmap bitmap = BitmapUtils.getBitmapFromMemCache(filterKey); + if (bitmap == null) { + final GPUImageFilter filter = item.getInstance(); + appExecutors.getTasksThread().submit(() -> { + GPUImage.getBitmapForMultipleFilters( + originalBitmap, + ImmutableList.builder().add(filter).addAll(tuneFilters).build(), + filteredBitmap -> { + BitmapUtils.addBitmapToMemoryCache(filterKey, filteredBitmap, true); + appExecutors.getMainThread().execute(() -> binding.getRoot().post(() -> binding.preview.setImageBitmap(filteredBitmap))); + } + ); + }); + return; + } + binding.getRoot().post(() -> binding.preview.setImageBitmap(bitmap)); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/FollowsViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/FollowsViewHolder.java new file mode 100755 index 0000000..0b5b81c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/FollowsViewHolder.java @@ -0,0 +1,29 @@ +package awais.instagrabber.adapters.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.databinding.ItemFollowBinding; +import awais.instagrabber.repositories.responses.User; + +public final class FollowsViewHolder extends RecyclerView.ViewHolder { + + private final ItemFollowBinding binding; + + public FollowsViewHolder(@NonNull final ItemFollowBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final User model, + final View.OnClickListener onClickListener) { + if (model == null) return; + itemView.setTag(model); + itemView.setOnClickListener(onClickListener); + binding.username.setUsername("@" + model.getUsername(), model.isVerified()); + binding.fullName.setText(model.getFullName()); + binding.profilePic.setImageURI(model.getProfilePicUrl()); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/HighlightViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/HighlightViewHolder.java new file mode 100755 index 0000000..8bcb0ea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/HighlightViewHolder.java @@ -0,0 +1,31 @@ +package awais.instagrabber.adapters.viewholder; + +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.databinding.ItemHighlightBinding; +import awais.instagrabber.repositories.responses.stories.Story; + +public final class HighlightViewHolder extends RecyclerView.ViewHolder { + + private final ItemHighlightBinding binding; + + public HighlightViewHolder(final ItemHighlightBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final Story model) { + if (model == null) return; + binding.title.setText(model.getTitle()); + binding.icon.setImageURI(model.getCoverMedia().getCroppedImageVersion().getUrl()); + // binding.getRoot().setOnClickListener(v -> { + // if (listener == null) return; + // listener.onFeedStoryClick(model, position); + // }); + // final ProfileModel profileModel = model.getProfileModel(); + // binding.title.setText(profileModel.getUsername()); + // binding.title.setAlpha(model.getFullyRead() ? 0.5F : 1.0F); + // binding.icon.setImageURI(profileModel.getSdProfilePic()); + // binding.icon.setAlpha(model.getFullyRead() ? 0.5F : 1.0F); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/NotificationViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/NotificationViewHolder.java new file mode 100644 index 0000000..153837e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/NotificationViewHolder.java @@ -0,0 +1,100 @@ +package awais.instagrabber.adapters.viewholder; + +import android.text.TextUtils; +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.NotificationsAdapter.OnNotificationClickListener; +import awais.instagrabber.databinding.ItemNotificationBinding; +import awais.instagrabber.models.enums.NotificationType; +import awais.instagrabber.repositories.responses.notification.Notification; +import awais.instagrabber.repositories.responses.notification.NotificationArgs; + +public final class NotificationViewHolder extends RecyclerView.ViewHolder { + private final ItemNotificationBinding binding; + + public NotificationViewHolder(final ItemNotificationBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final Notification model, + final OnNotificationClickListener notificationClickListener) { + if (model == null) return; + int text = -1; + CharSequence subtext = null; + final NotificationArgs args = model.getArgs(); + switch (model.getType()) { + case LIKE: + text = R.string.liked_notif; + break; + case COMMENT: // untested + text = R.string.comment_notif; + subtext = args.getText(); + break; + case TAGGED: + text = R.string.tagged_notif; + break; + case FOLLOW: + text = R.string.follow_notif; + break; + case REQUEST: + text = R.string.request_notif; + break; + case COMMENT_MENTION: + case COMMENT_LIKE: + case TAGGED_COMMENT: + case RESPONDED_STORY: + subtext = args.getText(); + break; + case AYML: + subtext = args.getFullName(); + break; + } + binding.tvSubComment.setText(model.getType() == NotificationType.AYML ? args.getText() : subtext); + if (text == -1 && subtext != null) { + binding.tvComment.setText(args.getText()); + binding.tvComment.setVisibility(TextUtils.isEmpty(args.getText()) || args.getText().equals(args.getFullName()) + ? View.GONE : View.VISIBLE); + binding.tvSubComment.setText(subtext); + binding.tvSubComment.setVisibility(model.getType() == NotificationType.AYML ? View.VISIBLE : View.GONE); + } else if (text != -1) { + binding.tvComment.setText(text); + binding.tvSubComment.setVisibility(subtext == null ? View.GONE : View.VISIBLE); + } + + binding.tvDate.setVisibility(model.getType() == NotificationType.AYML ? View.GONE : View.VISIBLE); + if (model.getType() != NotificationType.AYML) { + binding.tvDate.setText(args.getDateTime()); + } + + binding.isVerified.setVisibility(args.isVerified() ? View.VISIBLE : View.GONE); + + binding.tvUsername.setText(args.getUsername()); + binding.ivProfilePic.setImageURI(args.getProfilePic()); + binding.ivProfilePic.setOnClickListener(v -> { + if (notificationClickListener == null) return; + notificationClickListener.onProfileClick(args.getUsername()); + }); + + if (model.getType() == NotificationType.AYML) { + binding.ivPreviewPic.setVisibility(View.GONE); + } else if (args.getMedia() == null) { + binding.ivPreviewPic.setVisibility(View.INVISIBLE); + } else { + binding.ivPreviewPic.setVisibility(View.VISIBLE); + binding.ivPreviewPic.setImageURI(args.getMedia().get(0).getImage()); + binding.ivPreviewPic.setOnClickListener(v -> { + if (notificationClickListener == null) return; + notificationClickListener.onPreviewClick(model); + }); + } + + itemView.setOnClickListener(v -> { + if (notificationClickListener == null) return; + notificationClickListener.onNotificationClick(model); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java new file mode 100644 index 0000000..fbfdc6d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java @@ -0,0 +1,80 @@ +package awais.instagrabber.adapters.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.ItemSearchResultBinding; +import awais.instagrabber.fragments.search.SearchCategoryFragment.OnSearchItemClickListener; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.Place; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.search.SearchItem; + +public class SearchItemViewHolder extends RecyclerView.ViewHolder { + + private final ItemSearchResultBinding binding; + private final OnSearchItemClickListener onSearchItemClickListener; + + public SearchItemViewHolder(@NonNull final ItemSearchResultBinding binding, + final OnSearchItemClickListener onSearchItemClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onSearchItemClickListener = onSearchItemClickListener; + } + + public void bind(final SearchItem searchItem) { + if (searchItem == null) return; + final FavoriteType type = searchItem.getType(); + if (type == null) return; + String title; + String subtitle; + String picUrl; + boolean isVerified = false; + switch (type) { + case USER: + final User user = searchItem.getUser(); + title = "@" + user.getUsername(); + subtitle = user.getFullName(); + picUrl = user.getProfilePicUrl(); + isVerified = user.isVerified(); + break; + case HASHTAG: + final Hashtag hashtag = searchItem.getHashtag(); + title = "#" + hashtag.getName(); + subtitle = hashtag.getSearchResultSubtitle(); + picUrl = "res:/" + R.drawable.ic_hashtag; + break; + case LOCATION: + final Place place = searchItem.getPlace(); + title = place.getTitle(); + subtitle = place.getSubtitle(); + picUrl = "res:/" + R.drawable.ic_location; + break; + default: + return; + } + itemView.setOnClickListener(v -> { + if (onSearchItemClickListener != null) { + onSearchItemClickListener.onSearchItemClick(searchItem); + } + }); + binding.delete.setVisibility(searchItem.isRecent() ? View.VISIBLE : View.GONE); + if (searchItem.isRecent()) { + binding.delete.setEnabled(true); + binding.delete.setOnClickListener(v -> { + if (onSearchItemClickListener != null) { + binding.delete.setEnabled(false); + onSearchItemClickListener.onSearchItemDelete(searchItem, type); + } + }); + } + binding.title.setText(title); + binding.subtitle.setText(subtitle); + binding.profilePic.setImageURI(picUrl); + binding.verified.setVisibility(isVerified ? View.VISIBLE : View.GONE); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderItemViewHolder.java new file mode 100644 index 0000000..379e6c6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderItemViewHolder.java @@ -0,0 +1,21 @@ +package awais.instagrabber.adapters.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.adapters.SliderItemsAdapter; +import awais.instagrabber.repositories.responses.Media; + +public abstract class SliderItemViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = "FeedSliderItemViewHolder"; + + public SliderItemViewHolder(@NonNull final View itemView) { + super(itemView); + } + + public abstract void bind(final Media media, + final int position, + final SliderItemsAdapter.SliderCallback sliderCallback); +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderPhotoViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderPhotoViewHolder.java new file mode 100644 index 0000000..914ee5e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderPhotoViewHolder.java @@ -0,0 +1,76 @@ +package awais.instagrabber.adapters.viewholder; + +import android.graphics.drawable.Animatable; +import android.net.Uri; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.imagepipeline.image.ImageInfo; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; + +import awais.instagrabber.adapters.SliderItemsAdapter; +import awais.instagrabber.customviews.drawee.AnimatedZoomableController; +import awais.instagrabber.customviews.drawee.DoubleTapGestureListener; +import awais.instagrabber.databinding.ItemSliderPhotoBinding; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.ResponseBodyUtils; + +public class SliderPhotoViewHolder extends SliderItemViewHolder { + private static final String TAG = "FeedSliderPhotoViewHolder"; + + private final ItemSliderPhotoBinding binding; + + public SliderPhotoViewHolder(@NonNull final ItemSliderPhotoBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(@NonNull final Media model, + final int position, + final SliderItemsAdapter.SliderCallback sliderCallback) { + final ImageRequest requestBuilder = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(ResponseBodyUtils.getImageUrl(model))) + .setLocalThumbnailPreviewsEnabled(true) + .build(); + binding.getRoot() + .setController(Fresco.newDraweeControllerBuilder() + .setImageRequest(requestBuilder) + .setControllerListener(new BaseControllerListener() { + @Override + public void onFailure(final String id, final Throwable throwable) { + if (sliderCallback != null) { + sliderCallback.onThumbnailLoaded(position); + } + } + + @Override + public void onFinalImageSet(final String id, + final ImageInfo imageInfo, + final Animatable animatable) { + if (sliderCallback != null) { + sliderCallback.onThumbnailLoaded(position); + } + } + }) + .setLowResImageRequest(ImageRequest.fromUri(ResponseBodyUtils.getThumbUrl(model))) + .build()); + final DoubleTapGestureListener tapListener = new DoubleTapGestureListener(binding.getRoot()) { + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + if (sliderCallback != null) { + sliderCallback.onItemClicked(position, model, binding.getRoot()); + } + return super.onSingleTapConfirmed(e); + } + }; + binding.getRoot().setTapListener(tapListener); + final AnimatedZoomableController zoomableController = AnimatedZoomableController.newInstance(); + zoomableController.setMaxScaleFactor(3f); + binding.getRoot().setZoomableController(zoomableController); + binding.getRoot().setZoomingEnabled(true); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderVideoViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderVideoViewHolder.java new file mode 100644 index 0000000..832554b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/SliderVideoViewHolder.java @@ -0,0 +1,157 @@ +package awais.instagrabber.adapters.viewholder; + +import android.annotation.SuppressLint; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.ui.StyledPlayerView; + +import java.util.List; + +import awais.instagrabber.adapters.SliderItemsAdapter; +import awais.instagrabber.customviews.VideoPlayerCallbackAdapter; +import awais.instagrabber.customviews.VideoPlayerViewHelper; +import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.MediaCandidate; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SliderVideoViewHolder extends SliderItemViewHolder { + private static final String TAG = "SliderVideoViewHolder"; + + private final LayoutVideoPlayerWithThumbnailBinding binding; + private final boolean loadVideoOnItemClick; + + private VideoPlayerViewHelper videoPlayerViewHelper; + + @SuppressLint("ClickableViewAccessibility") + public SliderVideoViewHolder(@NonNull final LayoutVideoPlayerWithThumbnailBinding binding, + final boolean loadVideoOnItemClick) { + super(binding.getRoot()); + this.binding = binding; + this.loadVideoOnItemClick = loadVideoOnItemClick; + final GestureDetector.OnGestureListener videoPlayerViewGestureListener = new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + binding.playerView.performClick(); + return true; + } + }; + final GestureDetector gestureDetector = new GestureDetector(itemView.getContext(), videoPlayerViewGestureListener); + binding.playerView.setOnTouchListener((v, event) -> { + gestureDetector.onTouchEvent(event); + return true; + }); + } + + public void bind(@NonNull final Media media, + final int position, + final SliderItemsAdapter.SliderCallback sliderCallback) { + final float vol = settingsHelper.getBoolean(PreferenceKeys.MUTED_VIDEOS) ? 0f : 1f; + final VideoPlayerViewHelper.VideoPlayerCallback videoPlayerCallback = new VideoPlayerCallbackAdapter() { + + @Override + public void onThumbnailClick() { + if (sliderCallback != null) { + sliderCallback.onItemClicked(position, media, binding.getRoot()); + } + } + + @Override + public void onThumbnailLoaded() { + if (sliderCallback != null) { + sliderCallback.onThumbnailLoaded(position); + } + } + + @Override + public void onPlayerViewLoaded() { + // binding.itemFeedBottom.btnMute.setVisibility(View.VISIBLE); + final ViewGroup.LayoutParams layoutParams = binding.playerView.getLayoutParams(); + final int requiredWidth = Utils.displayMetrics.widthPixels; + final int resultingHeight = NumberUtils.getResultingHeight(requiredWidth, media.getOriginalHeight(), media.getOriginalWidth()); + layoutParams.width = requiredWidth; + layoutParams.height = resultingHeight; + binding.playerView.requestLayout(); + // setMuteIcon(vol == 0f && Utils.sessionVolumeFull ? 1f : vol); + } + + @Override + public void onPlay() { + if (sliderCallback != null) { + sliderCallback.onPlayerPlay(position); + } + } + + @Override + public void onPause() { + if (sliderCallback != null) { + sliderCallback.onPlayerPause(position); + } + } + + @Override + public void onRelease() { + if (sliderCallback != null) { + sliderCallback.onPlayerRelease(position); + } + } + + @Override + public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) { + if (sliderCallback != null) { + sliderCallback.onFullScreenModeChanged(isFullScreen, playerView); + } + } + + @Override + public boolean isInFullScreen() { + if (sliderCallback != null) { + return sliderCallback.isInFullScreen(); + } + return false; + } + }; + final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); + String videoUrl = null; + final List videoVersions = media.getVideoVersions(); + if (videoVersions != null && !videoVersions.isEmpty()) { + final MediaCandidate videoVersion = videoVersions.get(0); + if (videoVersion != null) { + videoUrl = videoVersion.getUrl(); + } + } + if (videoUrl == null) return; + videoPlayerViewHelper = new VideoPlayerViewHelper(binding.getRoot().getContext(), + binding, + videoUrl, + vol, + aspectRatio, + ResponseBodyUtils.getThumbUrl(media), + loadVideoOnItemClick, + videoPlayerCallback); + binding.playerView.setOnClickListener(v -> { + if (sliderCallback != null) { + sliderCallback.onItemClicked(position, media, binding.getRoot()); + } + }); + } + + public void pause() { + if (videoPlayerViewHelper == null) return; + videoPlayerViewHelper.pause(); + } + + public void releasePlayer() { + if (videoPlayerViewHelper == null) return; + videoPlayerViewHelper.releasePlayer(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/StoryListViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/StoryListViewHolder.java new file mode 100644 index 0000000..182ef65 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/StoryListViewHolder.java @@ -0,0 +1,83 @@ +package awais.instagrabber.adapters.viewholder; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.FeedStoriesListAdapter.OnFeedStoryClickListener; +import awais.instagrabber.adapters.HighlightStoriesListAdapter.OnHighlightStoryClickListener; +import awais.instagrabber.databinding.ItemNotificationBinding; +import awais.instagrabber.repositories.responses.stories.Story; +import awais.instagrabber.utils.ResponseBodyUtils; + +public final class StoryListViewHolder extends RecyclerView.ViewHolder { + private final ItemNotificationBinding binding; + + public StoryListViewHolder(final ItemNotificationBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final Story model, + final OnFeedStoryClickListener notificationClickListener) { + if (model == null) return; + + final int storiesCount = model.getMediaCount(); + binding.tvComment.setVisibility(View.VISIBLE); + binding.tvComment.setText(itemView.getResources().getQuantityString(R.plurals.stories_count, storiesCount, storiesCount)); + + binding.tvSubComment.setVisibility(View.GONE); + + binding.tvDate.setText(model.getDateTime()); + + binding.tvUsername.setText(model.getUser().getUsername()); + binding.ivProfilePic.setImageURI(model.getUser().getProfilePicUrl()); + binding.ivProfilePic.setOnClickListener(v -> { + if (notificationClickListener == null) return; + notificationClickListener.onProfileClick(model.getUser().getUsername()); + }); + + if (model.getItems() != null && model.getItems().size() > 0) { + binding.ivPreviewPic.setVisibility(View.VISIBLE); + binding.ivPreviewPic.setImageURI(ResponseBodyUtils.getThumbUrl(model.getItems().get(0))); + } else binding.ivPreviewPic.setVisibility(View.INVISIBLE); + + float alpha = model.getSeen() != null && model.getSeen().equals(model.getLatestReelMedia()) + ? 0.5F : 1.0F; + binding.ivProfilePic.setAlpha(alpha); + binding.ivPreviewPic.setAlpha(alpha); + binding.tvUsername.setAlpha(alpha); + binding.tvComment.setAlpha(alpha); + binding.tvDate.setAlpha(alpha); + + itemView.setOnClickListener(v -> { + if (notificationClickListener == null) return; + notificationClickListener.onFeedStoryClick(model); + }); + } + + public void bind(final Story model, + final int position, + final OnHighlightStoryClickListener notificationClickListener) { + if (model == null) return; + + final int storiesCount = model.getMediaCount(); + binding.tvComment.setVisibility(View.VISIBLE); + binding.tvComment.setText(itemView.getResources().getQuantityString(R.plurals.stories_count, storiesCount, storiesCount)); + + binding.tvSubComment.setVisibility(View.GONE); + + binding.tvUsername.setText(model.getDateTime()); + + binding.ivProfilePic.setVisibility(View.GONE); + + binding.ivPreviewPic.setVisibility(View.VISIBLE); + binding.ivPreviewPic.setImageURI(model.getCoverImageVersion().getUrl()); + + itemView.setOnClickListener(v -> { + if (notificationClickListener == null) return; + notificationClickListener.onHighlightClick(model, position); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/TabViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/TabViewHolder.java new file mode 100644 index 0000000..d4be872 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/TabViewHolder.java @@ -0,0 +1,88 @@ +package awais.instagrabber.adapters.viewholder; + +import android.annotation.SuppressLint; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.widget.ImageViewCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.color.MaterialColors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.TabsAdapter; +import awais.instagrabber.databinding.ItemTabOrderPrefBinding; +import awais.instagrabber.models.Tab; + +public class TabViewHolder extends RecyclerView.ViewHolder { + private final ItemTabOrderPrefBinding binding; + private final TabsAdapter.TabAdapterCallback tabAdapterCallback; + private final int highlightColor; + private final Drawable originalBgColor; + + private boolean draggable = true; + + @SuppressLint("ClickableViewAccessibility") + public TabViewHolder(@NonNull final ItemTabOrderPrefBinding binding, + @NonNull final TabsAdapter.TabAdapterCallback tabAdapterCallback) { + super(binding.getRoot()); + this.binding = binding; + this.tabAdapterCallback = tabAdapterCallback; + highlightColor = MaterialColors.getColor(itemView.getContext(), R.attr.colorControlHighlight, 0); + originalBgColor = itemView.getBackground(); + binding.handle.setOnTouchListener((v, event) -> { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + tabAdapterCallback.onStartDrag(this); + } + return true; + }); + } + + public void bind(@NonNull final Tab tab, + final boolean isInOthers, + final boolean isCurrentFull) { + draggable = !isInOthers; + binding.icon.setImageResource(tab.getIconResId()); + binding.title.setText(tab.getTitle()); + binding.handle.setVisibility(isInOthers ? View.GONE : View.VISIBLE); + binding.addRemove.setImageResource(isInOthers ? R.drawable.ic_round_add_circle_24 + : R.drawable.ic_round_remove_circle_24); + final ColorStateList tintList = ColorStateList.valueOf(ContextCompat.getColor( + itemView.getContext(), + isInOthers ? R.color.green_500 + : R.color.red_500)); + ImageViewCompat.setImageTintList(binding.addRemove, tintList); + binding.addRemove.setOnClickListener(v -> { + if (isInOthers) { + tabAdapterCallback.onAdd(tab); + return; + } + tabAdapterCallback.onRemove(tab); + }); + final boolean enabled = tab.isRemovable() + && !(isInOthers && isCurrentFull); // All slots are full in current + binding.addRemove.setEnabled(enabled); + binding.addRemove.setAlpha(enabled ? 1 : 0.5F); + } + + public boolean isDraggable() { + return draggable; + } + + public void setDragging(final boolean isDragging) { + if (isDragging) { + if (highlightColor != 0) { + itemView.setBackgroundColor(highlightColor); + } else { + itemView.setAlpha(0.5F); + } + return; + } + itemView.setAlpha(1); + itemView.setBackground(originalBgColor); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java new file mode 100644 index 0000000..4097d11 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java @@ -0,0 +1,180 @@ +package awais.instagrabber.adapters.viewholder; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.palette.graphics.Palette; +import androidx.recyclerview.widget.RecyclerView; + +import com.facebook.common.executors.CallerThreadExecutor; +import com.facebook.common.references.CloseableReference; +import com.facebook.datasource.DataSource; +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.imagepipeline.core.ImagePipeline; +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; +import com.facebook.imagepipeline.image.CloseableImage; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; + +import java.util.concurrent.atomic.AtomicInteger; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DiscoverTopicsAdapter; +import awais.instagrabber.adapters.SavedCollectionsAdapter; +import awais.instagrabber.databinding.ItemDiscoverTopicBinding; +import awais.instagrabber.repositories.responses.discover.TopicCluster; +import awais.instagrabber.repositories.responses.saved.SavedCollection; +import awais.instagrabber.utils.ResponseBodyUtils; + +public class TopicClusterViewHolder extends RecyclerView.ViewHolder { + private final ItemDiscoverTopicBinding binding; + private final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener; + private final SavedCollectionsAdapter.OnCollectionClickListener onCollectionClickListener; + + public TopicClusterViewHolder(@NonNull final ItemDiscoverTopicBinding binding, + final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener, + final SavedCollectionsAdapter.OnCollectionClickListener onCollectionClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onTopicClickListener = onTopicClickListener; + this.onCollectionClickListener = onCollectionClickListener; + } + + public void bind(final TopicCluster topicCluster) { + if (topicCluster == null) { + return; + } + final AtomicInteger titleColor = new AtomicInteger(-1); + final AtomicInteger backgroundColor = new AtomicInteger(-1); + if (onTopicClickListener != null) { + itemView.setOnClickListener(v -> onTopicClickListener.onTopicClick( + topicCluster, + binding.cover, + titleColor.get(), + backgroundColor.get() + )); + itemView.setOnLongClickListener(v -> { + onTopicClickListener.onTopicLongClick(topicCluster.getCoverMedia()); + return true; + }); + } + // binding.title.setTransitionName("title-" + topicCluster.getId()); + binding.cover.setTransitionName("cover-" + topicCluster.getId()); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(topicCluster.getCoverMedia()); + if (thumbUrl == null) { + binding.cover.setImageURI((String) null); + } else { + final ImageRequest imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(thumbUrl)) + .build(); + final ImagePipeline imagePipeline = Fresco.getImagePipeline(); + final DataSource> dataSource = imagePipeline + .fetchDecodedImage(imageRequest, CallerThreadExecutor.getInstance()); + dataSource.subscribe(new BaseBitmapDataSubscriber() { + @Override + public void onNewResultImpl(@Nullable Bitmap bitmap) { + if (dataSource.isFinished()) { + dataSource.close(); + } + if (bitmap != null) { + Palette.from(bitmap).generate(p -> { + final Resources resources = itemView.getResources(); + int titleTextColor = resources.getColor(R.color.white); + if (p != null) { + final Palette.Swatch swatch = p.getDominantSwatch(); + if (swatch != null) { + backgroundColor.set(swatch.getRgb()); + GradientDrawable gd = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{Color.TRANSPARENT, backgroundColor.get()}); + titleTextColor = swatch.getTitleTextColor(); + binding.background.setBackground(gd); + } + } + titleColor.set(titleTextColor); + binding.title.setTextColor(titleTextColor); + }); + } + } + + @Override + public void onFailureImpl(@NonNull DataSource dataSource) { + dataSource.close(); + } + }, CallerThreadExecutor.getInstance()); + binding.cover.setImageRequest(imageRequest); + } + binding.title.setText(topicCluster.getTitle()); + } + + public void bind(final SavedCollection topicCluster) { + if (topicCluster == null) { + return; + } + final AtomicInteger titleColor = new AtomicInteger(-1); + final AtomicInteger backgroundColor = new AtomicInteger(-1); + if (onCollectionClickListener != null) { + itemView.setOnClickListener(v -> onCollectionClickListener.onCollectionClick( + topicCluster, + binding.getRoot(), + binding.cover, + binding.title, + titleColor.get(), + backgroundColor.get() + )); + } + // binding.title.setTransitionName("title-" + topicCluster.getCollectionId()); + binding.cover.setTransitionName("cover-" + topicCluster.getCollectionId()); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(topicCluster.getCoverMediaList().get(0)); + if (thumbUrl == null) { + binding.cover.setImageURI((String) null); + } else { + final ImageRequest imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(thumbUrl)) + .build(); + final ImagePipeline imagePipeline = Fresco.getImagePipeline(); + final DataSource> dataSource = imagePipeline + .fetchDecodedImage(imageRequest, CallerThreadExecutor.getInstance()); + dataSource.subscribe(new BaseBitmapDataSubscriber() { + @Override + public void onNewResultImpl(@Nullable Bitmap bitmap) { + if (dataSource.isFinished()) { + dataSource.close(); + } + if (bitmap != null) { + Palette.from(bitmap).generate(p -> { + final Resources resources = itemView.getResources(); + int titleTextColor = resources.getColor(R.color.white); + if (p != null) { + final Palette.Swatch swatch = p.getDominantSwatch(); + if (swatch != null) { + backgroundColor.set(swatch.getRgb()); + GradientDrawable gd = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{Color.TRANSPARENT, backgroundColor.get()} + ); + titleTextColor = swatch.getTitleTextColor(); + binding.background.setBackground(gd); + } + } + titleColor.set(titleTextColor); + binding.title.setTextColor(titleTextColor); + }); + } + } + + @Override + public void onFailureImpl(@NonNull DataSource dataSource) { + dataSource.close(); + } + }, CallerThreadExecutor.getInstance()); + binding.cover.setImageRequest(imageRequest); + } + binding.title.setText(topicCluster.getCollectionName()); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/dialogs/KeywordsFilterDialogViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/dialogs/KeywordsFilterDialogViewHolder.java new file mode 100644 index 0000000..9930be5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/dialogs/KeywordsFilterDialogViewHolder.java @@ -0,0 +1,51 @@ +package awais.instagrabber.adapters.viewholder.dialogs; + +import android.content.Context; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.HashSet; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.KeywordsFilterAdapter; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.utils.SettingsHelper; + +public class KeywordsFilterDialogViewHolder extends RecyclerView.ViewHolder { + + private final Button deleteButton; + private final TextView item; + + public KeywordsFilterDialogViewHolder(@NonNull View itemView) { + super(itemView); + deleteButton = itemView.findViewById(R.id.keyword_delete); + item = itemView.findViewById(R.id.keyword_text); + } + + public void bind(ArrayList items, int position, Context context, KeywordsFilterAdapter adapter){ + item.setText(items.get(position)); + deleteButton.setOnClickListener(view -> { + final String s = items.get(position); + SettingsHelper settingsHelper = new SettingsHelper(context); + items.remove(position); + settingsHelper.putStringSet(PreferenceKeys.KEYWORD_FILTERS, new HashSet<>(items)); + adapter.notifyDataSetChanged(); + final String message = context.getString(R.string.removed_keywords, s); + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + }); + } + + public Button getDeleteButton(){ + return deleteButton; + } + + public TextView getTextView(){ + return item; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectInboxItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectInboxItemViewHolder.java new file mode 100644 index 0000000..2b0596b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectInboxItemViewHolder.java @@ -0,0 +1,147 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.content.res.Resources; +import android.graphics.Typeface; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.recyclerview.widget.RecyclerView; + +import com.facebook.drawee.view.SimpleDraweeView; +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectMessageInboxAdapter.OnItemClickListener; +import awais.instagrabber.databinding.LayoutDmInboxItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.DMUtils; +import awais.instagrabber.utils.TextUtils; + +public final class DirectInboxItemViewHolder extends RecyclerView.ViewHolder { + // private static final String TAG = "DMInboxItemVH"; + private final LayoutDmInboxItemBinding binding; + private final OnItemClickListener onClickListener; + private final List multipleProfilePics; + private final int childSmallSize; + private final int childTinySize; + + public DirectInboxItemViewHolder(@NonNull final LayoutDmInboxItemBinding binding, + final OnItemClickListener onClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onClickListener = onClickListener; + multipleProfilePics = ImmutableList.of( + binding.multiPic1, + binding.multiPic2, + binding.multiPic3 + ); + childSmallSize = itemView.getResources().getDimensionPixelSize(R.dimen.dm_inbox_avatar_size_small); + childTinySize = itemView.getResources().getDimensionPixelSize(R.dimen.dm_inbox_avatar_size_tiny); + } + + public void bind(final DirectThread thread) { + if (thread == null) return; + if (onClickListener != null) { + itemView.setOnClickListener((v) -> onClickListener.onItemClick(thread)); + } + setProfilePics(thread); + setTitle(thread); + final List items = thread.getItems(); + if (items == null || items.isEmpty()) return; + final DirectItem item = thread.getFirstDirectItem(); + if (item == null) return; + setDateTime(item); + setSubtitle(thread); + setReadState(thread); + } + + private void setProfilePics(@NonNull final DirectThread thread) { + final List users = thread.getUsers(); + if (users.size() > 1) { + binding.profilePic.setVisibility(View.GONE); + binding.multiPicContainer.setVisibility(View.VISIBLE); + for (int i = 0; i < Math.min(3, users.size()); ++i) { + final User user = users.get(i); + final SimpleDraweeView view = multipleProfilePics.get(i); + view.setVisibility(user == null ? View.GONE : View.VISIBLE); + if (user == null) return; + final String profilePicUrl = user.getProfilePicUrl(); + view.setImageURI(profilePicUrl); + setChildSize(view, users.size()); + if (i == 1) { + updateConstraints(view, users.size()); + } + view.requestLayout(); + } + return; + } + binding.profilePic.setVisibility(View.VISIBLE); + binding.multiPicContainer.setVisibility(View.GONE); + final String profilePicUrl = users.size() == 1 ? users.get(0).getProfilePicUrl() : null; + if (profilePicUrl == null) { + binding.profilePic.setController(null); + return; + } + binding.profilePic.setImageURI(profilePicUrl); + } + + private void updateConstraints(final SimpleDraweeView view, final int length) { + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) view.getLayoutParams(); + if (length >= 2) { + layoutParams.endToEnd = ConstraintSet.PARENT_ID; + layoutParams.bottomToBottom = ConstraintSet.PARENT_ID; + } + if (length == 3) { + layoutParams.startToStart = ConstraintSet.PARENT_ID; + layoutParams.topToTop = ConstraintSet.PARENT_ID; + } + } + + private void setChildSize(final SimpleDraweeView view, final int length) { + final int size = length == 3 ? childTinySize : childSmallSize; + final ConstraintLayout.LayoutParams viewLayoutParams = new ConstraintLayout.LayoutParams(size, size); + view.setLayoutParams(viewLayoutParams); + } + + private void setTitle(@NonNull final DirectThread thread) { + final String threadTitle = thread.getThreadTitle(); + binding.threadTitle.setText(threadTitle); + } + + private void setSubtitle(@NonNull final DirectThread thread) { + final Resources resources = itemView.getResources(); + final long viewerId = thread.getViewerId(); +// final DirectThreadDirectStory directStory = thread.getDirectStory(); +// if (directStory != null && !directStory.getItems().isEmpty()) { +// final DirectItem item = directStory.getItems().get(0); +// final MediaItemType mediaType = item.getVisualMedia().getMedia().getMediaType(); +// final String username = DMUtils.getUsername(thread.getUsers(), item.getUserId(), viewerId, resources); +// final String subtitle = DMUtils.getMediaSpecificSubtitle(username, resources, mediaType); +// binding.subtitle.setText(subtitle); +// return; +// } + final DirectItem item = thread.getFirstDirectItem(); + if (item == null) return; + final String subtitle = DMUtils.getMessageString(thread, resources, viewerId, item); + binding.subtitle.setText(subtitle != null ? subtitle : ""); + } + + private void setDateTime(@NonNull final DirectItem item) { + final long timestamp = item.getTimestamp() / 1000; + final String dateTimeString = TextUtils.getRelativeDateTimeString(timestamp); + binding.tvDate.setText(dateTimeString); + } + + private void setReadState(@NonNull final DirectThread thread) { + final boolean read = DMUtils.isRead(thread); + binding.unread.setVisibility(read ? View.GONE : View.VISIBLE); + binding.threadTitle.setTypeface(null, read ? Typeface.NORMAL : Typeface.BOLD); + binding.subtitle.setTypeface(null, read ? Typeface.NORMAL : Typeface.BOLD); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemActionLogViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemActionLogViewHolder.java new file mode 100644 index 0000000..c212efb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemActionLogViewHolder.java @@ -0,0 +1,101 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmActionLogBinding; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemActionLog; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.TextRange; +import awais.instagrabber.utils.TextUtils; + +public class DirectItemActionLogViewHolder extends DirectItemViewHolder { + private static final String TAG = DirectItemActionLogViewHolder.class.getSimpleName(); + + private final LayoutDmActionLogBinding binding; + + public DirectItemActionLogViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + final LayoutDmActionLogBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + binding.tvMessage.setMovementMethod(LinkMovementMethod.getInstance()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final DirectItemActionLog actionLog = directItemModel.getActionLog(); + final String text = actionLog.getDescription(); + final SpannableStringBuilder sb = new SpannableStringBuilder(text); + final List bold = actionLog.getBold(); + if (bold != null && !bold.isEmpty()) { + for (final TextRange textRange : bold) { + final StyleSpan boldStyleSpan = new StyleSpan(Typeface.BOLD); + sb.setSpan(boldStyleSpan, textRange.getStart(), textRange.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + } + final List textAttributes = actionLog.getTextAttributes(); + if (textAttributes != null && !textAttributes.isEmpty()) { + for (final TextRange textAttribute : textAttributes) { + if (!TextUtils.isEmpty(textAttribute.getColor())) { + final ForegroundColorSpan colorSpan = new ForegroundColorSpan(itemView.getResources().getColor(R.color.deep_orange_400)); + sb.setSpan(colorSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + if (!TextUtils.isEmpty(textAttribute.getIntent())) { + final ClickableSpan clickableSpan = new ClickableSpan() { + @Override + public void onClick(@NonNull final View widget) { + handleDeepLink(textAttribute.getIntent()); + } + }; + sb.setSpan(clickableSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + } + } + binding.tvMessage.setText(sb); + } + + @Override + protected boolean allowMessageDirectionGravity() { + return false; + } + + @Override + protected boolean showUserDetailsInGroup() { + return false; + } + + @Override + protected boolean showMessageInfo() { + return false; + } + + @Override + protected boolean allowLongClick() { + return false; + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemAnimatedMediaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemAnimatedMediaViewHolder.java new file mode 100644 index 0000000..5b3b1e3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemAnimatedMediaViewHolder.java @@ -0,0 +1,84 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.databinding.LayoutDmAnimatedMediaBinding; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.repositories.responses.AnimatedMediaFixedHeight; +import awais.instagrabber.repositories.responses.AnimatedMediaImages; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemAnimatedMedia; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.Utils; + +public class DirectItemAnimatedMediaViewHolder extends DirectItemViewHolder { + + private final LayoutDmAnimatedMediaBinding binding; + + public DirectItemAnimatedMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmAnimatedMediaBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem item, final MessageDirection messageDirection) { + final DirectItemAnimatedMedia animatedMediaModel = item.getAnimatedMedia(); + final AnimatedMediaImages images = animatedMediaModel.getImages(); + if (images == null) return; + final AnimatedMediaFixedHeight fixedHeight = images.getFixedHeight(); + if (fixedHeight == null) return; + final String url = fixedHeight.getWebp(); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( + fixedHeight.getHeight(), + fixedHeight.getWidth(), + mediaImageMaxHeight, + mediaImageMaxWidth + ); + binding.ivAnimatedMessage.setVisibility(View.VISIBLE); + final ViewGroup.LayoutParams layoutParams = binding.ivAnimatedMessage.getLayoutParams(); + final int width = widthHeight.first; + final int height = widthHeight.second; + layoutParams.width = width; + layoutParams.height = height; + binding.ivAnimatedMessage.requestLayout(); + binding.ivAnimatedMessage.setController(Fresco.newDraweeControllerBuilder() + .setUri(url) + .setAutoPlayAnimations(true) + .build()); + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } + + @Override + protected List getLongClickOptions() { + return ImmutableList.of( + new DirectItemContextMenu.MenuItem(R.id.detail, R.string.dms_inbox_giphy, item -> { + Utils.openURL(itemView.getContext(), "https://giphy.com/gifs/" + item.getAnimatedMedia().getId()); + return null; + }) + ); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemDefaultViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemDefaultViewHolder.java new file mode 100644 index 0000000..c9c8666 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemDefaultViewHolder.java @@ -0,0 +1,50 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmTextBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; + +public class DirectItemDefaultViewHolder extends DirectItemViewHolder { + + private final LayoutDmTextBinding binding; + + public DirectItemDefaultViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmTextBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final Context context = itemView.getContext(); + binding.tvMessage.setText(context.getText(R.string.dms_inbox_raven_message_unknown)); + } + + @Override + protected boolean showBackground() { + return true; + } + + @Override + protected boolean allowLongClick() { + return false; + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java new file mode 100644 index 0000000..af04666 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java @@ -0,0 +1,36 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmLikeBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; + +public class DirectItemLikeViewHolder extends DirectItemViewHolder { + + public DirectItemLikeViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmLikeBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) {} + + @Override + protected boolean canForward() { + return false; + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLinkViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLinkViewHolder.java new file mode 100644 index 0000000..98c9fb1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLinkViewHolder.java @@ -0,0 +1,102 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmLinkBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemLink; +import awais.instagrabber.repositories.responses.directmessages.DirectItemLinkContext; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +public class DirectItemLinkViewHolder extends DirectItemViewHolder { + + private final LayoutDmLinkBinding binding; + + public DirectItemLinkViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + final LayoutDmLinkBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + final int width = windowWidth - margin - dmRadiusSmall; + final ViewGroup.LayoutParams layoutParams = binding.preview.getLayoutParams(); + layoutParams.width = width; + binding.preview.requestLayout(); + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem item, final MessageDirection messageDirection) { + final DirectItemLink link = item.getLink(); + final DirectItemLinkContext linkContext = link.getLinkContext(); + final String linkImageUrl = linkContext.getLinkImageUrl(); + if (TextUtils.isEmpty(linkImageUrl)) { + binding.preview.setVisibility(View.GONE); + } else { + binding.preview.setVisibility(View.VISIBLE); + binding.preview.setImageURI(linkImageUrl); + } + if (TextUtils.isEmpty(linkContext.getLinkTitle())) { + binding.title.setVisibility(View.GONE); + } else { + binding.title.setVisibility(View.VISIBLE); + binding.title.setText(linkContext.getLinkTitle()); + } + if (TextUtils.isEmpty(linkContext.getLinkSummary())) { + binding.summary.setVisibility(View.GONE); + } else { + binding.summary.setVisibility(View.VISIBLE); + binding.summary.setText(linkContext.getLinkSummary()); + } + if (TextUtils.isEmpty(linkContext.getLinkUrl())) { + binding.url.setVisibility(View.GONE); + } else { + binding.url.setVisibility(View.VISIBLE); + binding.url.setText(linkContext.getLinkUrl()); + } + binding.text.setText(link.getText()); + setupListeners(linkContext); + } + + private void setupListeners(final DirectItemLinkContext linkContext) { + setupRamboTextListeners(binding.text); + final View.OnClickListener onClickListener = v -> openURL(linkContext.getLinkUrl()); + binding.preview.setOnClickListener(onClickListener); + // binding.preview.setOnLongClickListener(v -> itemView.performLongClick()); + binding.title.setOnClickListener(onClickListener); + binding.summary.setOnClickListener(onClickListener); + binding.url.setOnClickListener(onClickListener); + } + + @Override + protected boolean showBackground() { + return true; + } + + @Override + protected List getLongClickOptions() { + return ImmutableList.of( + new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy, item -> { + final DirectItemLink link = item.getLink(); + if (link == null || TextUtils.isEmpty(link.getText())) return null; + Utils.copyText(itemView.getContext(), link.getText()); + return null; + }) + ); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaShareViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaShareViewHolder.java new file mode 100644 index 0000000..1eb41c1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaShareViewHolder.java @@ -0,0 +1,197 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; + +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.RoundingParams; +import com.google.common.collect.ImmutableList; + +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmMediaShareBinding; +import awais.instagrabber.models.enums.DirectItemType; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Caption; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemClip; +import awais.instagrabber.repositories.responses.directmessages.DirectItemFelixShare; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; + +public class DirectItemMediaShareViewHolder extends DirectItemViewHolder { + private static final String TAG = DirectItemMediaShareViewHolder.class.getSimpleName(); + + private final LayoutDmMediaShareBinding binding; + private final RoundingParams incomingRoundingParams; + private final RoundingParams outgoingRoundingParams; + private DirectItemType itemType; + private Caption caption; + + public DirectItemMediaShareViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmMediaShareBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + incomingRoundingParams = RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius); + outgoingRoundingParams = RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem item, final MessageDirection messageDirection) { + binding.topBg.setBackgroundResource(messageDirection == MessageDirection.INCOMING + ? R.drawable.bg_media_share_top_incoming + : R.drawable.bg_media_share_top_outgoing); + Media media = getMedia(item); + if (media == null) return; + itemView.post(() -> { + setupUser(media); + setupCaption(media); + }); + final int index; + final Media toDisplay; + final MediaItemType mediaType = media.getType(); + switch (mediaType) { + case MEDIA_TYPE_SLIDER: + toDisplay = media.getCarouselMedia().stream() + .filter(m -> media.getCarouselShareChildMediaId() != null && + media.getCarouselShareChildMediaId().equals(m.getId())) + .findAny() + .orElse(media.getCarouselMedia().get(0)); + index = media.getCarouselMedia().indexOf(toDisplay); + break; + default: + toDisplay = media; + index = 0; + } + itemView.post(() -> { + setupTypeIndicator(mediaType); + setupPreview(toDisplay, messageDirection); + }); + itemView.setOnClickListener(v -> openMedia(media, index)); + } + + private void setupTypeIndicator(final MediaItemType mediaType) { + final boolean showTypeIcon = mediaType == MediaItemType.MEDIA_TYPE_VIDEO || mediaType == MediaItemType.MEDIA_TYPE_SLIDER; + if (!showTypeIcon) { + binding.typeIcon.setVisibility(View.GONE); + } else { + binding.typeIcon.setVisibility(View.VISIBLE); + binding.typeIcon.setImageResource(mediaType == MediaItemType.MEDIA_TYPE_VIDEO + ? R.drawable.ic_video_24 + : R.drawable.ic_checkbox_multiple_blank_stroke); + } + } + + private void setupPreview(@NonNull final Media media, + final MessageDirection messageDirection) { + final String url = ResponseBodyUtils.getThumbUrl(media); + if (Objects.equals(url, binding.mediaPreview.getTag())) { + return; + } + final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING ? incomingRoundingParams : outgoingRoundingParams; + binding.mediaPreview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) + .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) + .setRoundingParams(roundingParams) + .build()); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( + media.getOriginalHeight(), + media.getOriginalWidth(), + mediaImageMaxHeight, + mediaImageMaxWidth + ); + final ViewGroup.LayoutParams layoutParams = binding.mediaPreview.getLayoutParams(); + layoutParams.width = widthHeight.first; + layoutParams.height = widthHeight.second; + binding.mediaPreview.requestLayout(); + binding.mediaPreview.setTag(url); + binding.mediaPreview.setImageURI(url); + } + + private void setupCaption(@NonNull final Media media) { + caption = media.getCaption(); + if (caption != null) { + binding.caption.setVisibility(View.VISIBLE); + binding.caption.setText(caption.getText()); + binding.caption.setEllipsize(TextUtils.TruncateAt.END); + binding.caption.setMaxLines(2); + } else { + binding.caption.setVisibility(View.GONE); + } + } + + private void setupUser(@NonNull final Media media) { + final User user = media.getUser(); + if (user != null) { + binding.username.setVisibility(View.VISIBLE); + binding.profilePic.setVisibility(View.VISIBLE); + binding.username.setText(user.getUsername()); + binding.profilePic.setImageURI(user.getProfilePicUrl()); + } else { + binding.username.setVisibility(View.GONE); + binding.profilePic.setVisibility(View.GONE); + } + } + + @Nullable + private Media getMedia(@NonNull final DirectItem item) { + Media media = null; + itemType = item.getItemType(); + if (itemType == DirectItemType.MEDIA_SHARE) { + media = item.getMediaShare(); + } else if (itemType == DirectItemType.CLIP) { + final DirectItemClip clip = item.getClip(); + if (clip == null) return null; + media = clip.getClip(); + } else if (itemType == DirectItemType.FELIX_SHARE) { + final DirectItemFelixShare felixShare = item.getFelixShare(); + if (felixShare == null) return null; + media = felixShare.getVideo(); + } + return media; + } + + @Override + protected int getReactionsTranslationY() { + return reactionTranslationYType2; + } + + @Override + public int getSwipeDirection() { + if (itemType != null && (itemType == DirectItemType.CLIP || itemType == DirectItemType.FELIX_SHARE)) { + return ItemTouchHelper.ACTION_STATE_IDLE; + } + return super.getSwipeDirection(); + } + + @Override + protected List getLongClickOptions() { + final ImmutableList.Builder builder = ImmutableList.builder(); + if (caption != null && !TextUtils.isEmpty(caption.getText())) { + builder.add(new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy_caption, item -> { + Utils.copyText(itemView.getContext(), caption.getText()); + return null; + })); + } + return builder.build(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaViewHolder.java new file mode 100644 index 0000000..b4e01c7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaViewHolder.java @@ -0,0 +1,72 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.RoundingParams; + +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmMediaBinding; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; + +public class DirectItemMediaViewHolder extends DirectItemViewHolder { + + private final LayoutDmMediaBinding binding; + private final RoundingParams incomingRoundingParams; + private final RoundingParams outgoingRoundingParams; + + public DirectItemMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmMediaBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + incomingRoundingParams = RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius); + outgoingRoundingParams = RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING ? incomingRoundingParams : outgoingRoundingParams; + binding.mediaPreview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) + .setRoundingParams(roundingParams) + .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) + .build()); + final Media media = directItemModel.getMedia(); + itemView.setOnClickListener(v -> openMedia(media, -1)); + final MediaItemType modelMediaType = media.getType(); + binding.typeIcon.setVisibility(modelMediaType == MediaItemType.MEDIA_TYPE_VIDEO || modelMediaType == MediaItemType.MEDIA_TYPE_SLIDER + ? View.VISIBLE + : View.GONE); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( + media.getOriginalHeight(), + media.getOriginalWidth(), + mediaImageMaxHeight, + mediaImageMaxWidth + ); + final ViewGroup.LayoutParams layoutParams = binding.mediaPreview.getLayoutParams(); + final int width = widthHeight.first; + layoutParams.width = width; + layoutParams.height = widthHeight.second; + binding.mediaPreview.requestLayout(); + binding.bgTime.getLayoutParams().width = width; + binding.bgTime.requestLayout(); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(media); + binding.mediaPreview.setImageURI(thumbUrl); + } + +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemPlaceholderViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemPlaceholderViewHolder.java new file mode 100644 index 0000000..f903e73 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemPlaceholderViewHolder.java @@ -0,0 +1,47 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmStoryShareBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; + +public class DirectItemPlaceholderViewHolder extends DirectItemViewHolder { + + private final LayoutDmStoryShareBinding binding; + + public DirectItemPlaceholderViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + final LayoutDmStoryShareBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + binding.shareInfo.setText(directItemModel.getPlaceholder().getTitle()); + binding.text.setVisibility(View.VISIBLE); + binding.text.setText(directItemModel.getPlaceholder().getMessage()); + binding.ivMediaPreview.setVisibility(View.GONE); + binding.typeIcon.setVisibility(View.GONE); + } + + @Override + protected boolean allowLongClick() { + return false; + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemProfileViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemProfileViewHolder.java new file mode 100644 index 0000000..26f8bf8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemProfileViewHolder.java @@ -0,0 +1,138 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.content.res.Resources; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.RoundingParams; +import com.facebook.drawee.view.SimpleDraweeView; +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmProfileBinding; +import awais.instagrabber.models.enums.DirectItemType; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; + +public class DirectItemProfileViewHolder extends DirectItemViewHolder { + + private final LayoutDmProfileBinding binding; + private final ImmutableList previewViews; + + public DirectItemProfileViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmProfileBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + previewViews = ImmutableList.of( + binding.preview1, + binding.preview2, + binding.preview3, + binding.preview4, + binding.preview5, + binding.preview6 + ); + } + + @Override + public void bindItem(@NonNull final DirectItem item, + final MessageDirection messageDirection) { + binding.getRoot().setBackgroundResource(messageDirection == MessageDirection.INCOMING + ? R.drawable.bg_speech_bubble_incoming + : R.drawable.bg_speech_bubble_outgoing); + if (item.getItemType() == DirectItemType.PROFILE) { + setProfile(item); + } else if (item.getItemType() == DirectItemType.LOCATION) { + setLocation(item); + } else { + return; + } + for (final SimpleDraweeView previewView : previewViews) { + previewView.setImageURI((String) null); + } + final List previewMedias = item.getPreviewMedias(); + if (previewMedias == null || previewMedias.size() <= 0) { + binding.firstRow.setVisibility(View.GONE); + binding.secondRow.setVisibility(View.GONE); + return; + } + final Resources resources = itemView.getResources(); + if (previewMedias.size() <= 3) { + binding.firstRow.setVisibility(View.VISIBLE); + binding.secondRow.setVisibility(View.GONE); + binding.preview1.setHierarchy(new GenericDraweeHierarchyBuilder(resources) + .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, 0, dmRadius)) + .build()); + binding.preview3.setHierarchy(new GenericDraweeHierarchyBuilder(resources) + .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, dmRadius, 0)) + .build()); + } + if (previewMedias.size() > 3) { + binding.preview4.setHierarchy(new GenericDraweeHierarchyBuilder(resources) + .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, 0, dmRadius)) + .build()); + binding.preview6.setHierarchy(new GenericDraweeHierarchyBuilder(resources) + .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, dmRadius, 0)) + .build()); + } + for (int i = 0; i < previewMedias.size(); i++) { + final Media previewMedia = previewMedias.get(i); + if (previewMedia == null) continue; + final String url = ResponseBodyUtils.getThumbUrl(previewMedia); + if (url == null) continue; + previewViews.get(i).setImageURI(url); + } + } + + private void setProfile(@NonNull final DirectItem item) { + final User profile = item.getProfile(); + if (profile == null) return; + binding.profilePic.setImageURI(profile.getProfilePicUrl()); + binding.username.setText(profile.getUsername()); + final String fullName = profile.getFullName(); + if (!TextUtils.isEmpty(fullName)) { + binding.fullName.setVisibility(View.VISIBLE); + binding.fullName.setText(fullName); + } else { + binding.fullName.setVisibility(View.GONE); + } + binding.isVerified.setVisibility(profile.isVerified() ? View.VISIBLE : View.GONE); + itemView.setOnClickListener(v -> openProfile(profile.getUsername())); + } + + private void setLocation(@NonNull final DirectItem item) { + final Location location = item.getLocation(); + if (location == null) return; + binding.profilePic.setVisibility(View.GONE); + binding.username.setText(location.getName()); + final String address = location.getAddress(); + if (!TextUtils.isEmpty(address)) { + binding.fullName.setText(address); + binding.fullName.setVisibility(View.VISIBLE); + } else { + binding.fullName.setVisibility(View.GONE); + } + binding.isVerified.setVisibility(View.GONE); + itemView.setOnClickListener(v -> openLocation(location.getPk())); + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java new file mode 100644 index 0000000..02051dd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java @@ -0,0 +1,191 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.RoundingParams; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmRavenMediaBinding; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.models.enums.RavenMediaViewMode; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; + +public class DirectItemRavenMediaViewHolder extends DirectItemViewHolder { + + private final LayoutDmRavenMediaBinding binding; + private final int maxWidth; + + public DirectItemRavenMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmRavenMediaBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + maxWidth = windowWidth - margin - dmRadiusSmall; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final DirectItemVisualMedia visualMedia = directItemModel.getVisualMedia(); + final Media media = visualMedia.getMedia(); + if (media == null) return; + setExpiryInfo(visualMedia); + setPreview(visualMedia, messageDirection); + final boolean expired = TextUtils.isEmpty(media.getId()); + if (expired) return; + itemView.setOnClickListener(v -> openMedia(media, -1)); + /*final boolean isExpired = visualMedia == null || (mediaModel = visualMedia.getMedia()) == null || + TextUtils.isEmpty(mediaModel.getThumbUrl()) && mediaModel.getPk() < 1; + + RavenExpiringMediaActionSummary mediaActionSummary = null; + if (visualMedia != null) { + mediaActionSummary = visualMedia.getExpiringMediaActionSummary(); + } + binding.mediaExpiredIcon.setVisibility(isExpired ? View.VISIBLE : View.GONE); + + int textRes = R.string.dms_inbox_raven_media_unknown; + if (isExpired) textRes = R.string.dms_inbox_raven_media_expired; + + if (!isExpired) { + if (mediaActionSummary != null) { + final ActionType expiringMediaType = mediaActionSummary.getType(); + + if (expiringMediaType == ActionType.DELIVERED) + textRes = R.string.dms_inbox_raven_media_delivered; + else if (expiringMediaType == ActionType.SENT) + textRes = R.string.dms_inbox_raven_media_sent; + else if (expiringMediaType == ActionType.OPENED) + textRes = R.string.dms_inbox_raven_media_opened; + else if (expiringMediaType == ActionType.REPLAYED) + textRes = R.string.dms_inbox_raven_media_replayed; + else if (expiringMediaType == ActionType.SENDING) + textRes = R.string.dms_inbox_raven_media_sending; + else if (expiringMediaType == ActionType.BLOCKED) + textRes = R.string.dms_inbox_raven_media_blocked; + else if (expiringMediaType == ActionType.SUGGESTED) + textRes = R.string.dms_inbox_raven_media_suggested; + else if (expiringMediaType == ActionType.SCREENSHOT) + textRes = R.string.dms_inbox_raven_media_screenshot; + else if (expiringMediaType == ActionType.CANNOT_DELIVER) + textRes = R.string.dms_inbox_raven_media_cant_deliver; + } + + final RavenMediaViewMode ravenMediaViewMode = visualMedia.getViewType(); + if (ravenMediaViewMode == RavenMediaViewMode.PERMANENT || ravenMediaViewMode == RavenMediaViewMode.REPLAYABLE) { + final MediaItemType mediaType = mediaModel.getMediaType(); + textRes = -1; + binding.typeIcon.setVisibility(mediaType == MediaItemType.MEDIA_TYPE_VIDEO || mediaType == MediaItemType.MEDIA_TYPE_SLIDER + ? View.VISIBLE + : View.GONE); + final Pair widthHeight = NumberUtils.calculateWidthHeight( + mediaModel.getHeight(), + mediaModel.getWidth(), + maxHeight, + maxWidth + ); + final ViewGroup.LayoutParams layoutParams = binding.ivMediaPreview.getLayoutParams(); + layoutParams.width = widthHeight.first != null ? widthHeight.first : 0; + layoutParams.height = widthHeight.second != null ? widthHeight.second : 0; + binding.ivMediaPreview.requestLayout(); + binding.ivMediaPreview.setImageURI(mediaModel.getThumbUrl()); + } + } + if (textRes != -1) { + binding.tvMessage.setText(context.getText(textRes)); + binding.tvMessage.setVisibility(View.VISIBLE); + }*/ + } + + private void setExpiryInfo(final DirectItemVisualMedia visualMedia) { + final Media media = visualMedia.getMedia(); + final RavenMediaViewMode viewMode = visualMedia.getViewMode(); + if (viewMode != RavenMediaViewMode.PERMANENT) { + final MediaItemType mediaType = media.getType(); + final boolean expired = TextUtils.isEmpty(media.getId()); + final int info; + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + if (expired) { + info = R.string.raven_image_expired; + break; + } + info = R.string.raven_image_info; + break; + case MEDIA_TYPE_VIDEO: + if (expired) { + info = R.string.raven_video_expired; + break; + } + info = R.string.raven_video_info; + break; + default: + if (expired) { + info = R.string.raven_msg_expired; + break; + } + info = R.string.raven_msg_info; + break; + } + binding.expiryInfo.setVisibility(View.VISIBLE); + binding.expiryInfo.setText(info); + return; + } + binding.expiryInfo.setVisibility(View.GONE); + } + + private void setPreview(final DirectItemVisualMedia visualMedia, + final MessageDirection messageDirection) { + final Media media = visualMedia.getMedia(); + final boolean expired = TextUtils.isEmpty(media.getId()); + if (expired) { + binding.preview.setVisibility(View.GONE); + binding.typeIcon.setVisibility(View.GONE); + return; + } + final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING + ? RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius) + : RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); + binding.preview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) + .setRoundingParams(roundingParams) + .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) + .build()); + final MediaItemType modelMediaType = media.getType(); + binding.typeIcon.setVisibility(modelMediaType == MediaItemType.MEDIA_TYPE_VIDEO || modelMediaType == MediaItemType.MEDIA_TYPE_SLIDER + ? View.VISIBLE + : View.GONE); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( + media.getOriginalHeight(), + media.getOriginalWidth(), + mediaImageMaxHeight, + maxWidth + ); + final ViewGroup.LayoutParams layoutParams = binding.preview.getLayoutParams(); + layoutParams.width = widthHeight.first; + layoutParams.height = widthHeight.second; + binding.preview.requestLayout(); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(media); + binding.preview.setImageURI(thumbUrl); + } + + @Override + protected boolean allowLongClick() { + return false; // disabling until confirmed + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java new file mode 100644 index 0000000..5f78e2c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java @@ -0,0 +1,194 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.Gravity; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; + +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.RoundingParams; +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmReelShareBinding; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemReelShare; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +public class DirectItemReelShareViewHolder extends DirectItemViewHolder { + + private final LayoutDmReelShareBinding binding; + private String type; + + public DirectItemReelShareViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmReelShareBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem item, final MessageDirection messageDirection) { + final DirectItemReelShare reelShare = item.getReelShare(); + type = reelShare.getType(); + if (type == null) return; + final boolean isSelf = isSelf(item); + final Media media = reelShare.getMedia(); + if (media == null) return; + final User user = media.getUser(); + if (user == null) return; + final boolean expired = media.getType() == null; + if (expired) { + binding.preview.setVisibility(View.GONE); + binding.typeIcon.setVisibility(View.GONE); + binding.quoteLine.setVisibility(View.GONE); + binding.reaction.setVisibility(View.GONE); + } else { + binding.preview.setVisibility(View.VISIBLE); + binding.typeIcon.setVisibility(View.VISIBLE); + binding.quoteLine.setVisibility(View.VISIBLE); + binding.reaction.setVisibility(View.VISIBLE); + } + setGravity(messageDirection, expired); + if (type.equals("reply")) { + setReply(messageDirection, reelShare, isSelf); + } + if (type.equals("reaction")) { + setReaction(messageDirection, reelShare, isSelf, expired); + } + if (type.equals("mention")) { + setMention(isSelf); + } + if (!expired) { + setPreview(media); + itemView.setOnClickListener(v -> openMedia(media, -1)); + } + } + + private void setGravity(final MessageDirection messageDirection, final boolean expired) { + final boolean isIncoming = messageDirection == MessageDirection.INCOMING; + binding.shareInfo.setGravity(isIncoming ? Gravity.START : Gravity.END); + if (!expired) { + binding.quoteLine.setVisibility(isIncoming ? View.VISIBLE : View.GONE); + binding.quoteLineEnd.setVisibility(isIncoming ? View.GONE : View.VISIBLE); + } + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) binding.quoteLine.getLayoutParams(); + layoutParams.horizontalBias = isIncoming ? 0 : 1; + final ConstraintLayout.LayoutParams messageLayoutParams = (ConstraintLayout.LayoutParams) binding.message.getLayoutParams(); + messageLayoutParams.startToStart = isIncoming ? ConstraintLayout.LayoutParams.PARENT_ID : ConstraintLayout.LayoutParams.UNSET; + messageLayoutParams.endToEnd = isIncoming ? ConstraintLayout.LayoutParams.UNSET : ConstraintLayout.LayoutParams.PARENT_ID; + messageLayoutParams.setMarginStart(isIncoming ? messageInfoPaddingSmall : 0); + messageLayoutParams.setMarginEnd(isIncoming ? 0 : messageInfoPaddingSmall); + final ConstraintLayout.LayoutParams reactionLayoutParams = (ConstraintLayout.LayoutParams) binding.reaction.getLayoutParams(); + final int previewId = binding.preview.getId(); + if (isIncoming) { + reactionLayoutParams.startToEnd = previewId; + reactionLayoutParams.endToEnd = previewId; + reactionLayoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET; + reactionLayoutParams.endToStart = ConstraintLayout.LayoutParams.UNSET; + } else { + reactionLayoutParams.startToStart = previewId; + reactionLayoutParams.endToStart = previewId; + reactionLayoutParams.startToEnd = ConstraintLayout.LayoutParams.UNSET; + reactionLayoutParams.endToEnd = ConstraintLayout.LayoutParams.UNSET; + } + } + + private void setReply(final MessageDirection messageDirection, + final DirectItemReelShare reelShare, + final boolean isSelf) { + final int info = isSelf ? R.string.replied_story_outgoing : R.string.replied_story_incoming; + binding.shareInfo.setText(info); + binding.reaction.setVisibility(View.GONE); + final String text = reelShare.getText(); + if (TextUtils.isEmpty(text)) { + binding.message.setVisibility(View.GONE); + return; + } + setMessage(messageDirection, text); + } + + private void setReaction(final MessageDirection messageDirection, + final DirectItemReelShare reelShare, + final boolean isSelf, + final boolean expired) { + final int info = isSelf ? R.string.reacted_story_outgoing : R.string.reacted_story_incoming; + binding.shareInfo.setText(info); + binding.message.setVisibility(View.GONE); + final String text = reelShare.getText(); + if (TextUtils.isEmpty(text)) { + binding.reaction.setVisibility(View.GONE); + return; + } + if (expired) { + setMessage(messageDirection, text); + return; + } + binding.reaction.setVisibility(View.VISIBLE); + binding.reaction.setText(text); + } + + private void setMention(final boolean isSelf) { + final int info = isSelf ? R.string.mentioned_story_outgoing : R.string.mentioned_story_incoming; + binding.shareInfo.setText(info); + binding.message.setVisibility(View.GONE); + binding.reaction.setVisibility(View.GONE); + } + + private void setMessage(final MessageDirection messageDirection, final String text) { + binding.message.setVisibility(View.VISIBLE); + binding.message.setBackgroundResource(messageDirection == MessageDirection.INCOMING + ? R.drawable.bg_speech_bubble_incoming + : R.drawable.bg_speech_bubble_outgoing); + binding.message.setText(text); + } + + private void setPreview(final Media media) { + final MediaItemType mediaType = media.getType(); + if (mediaType == null) return; + binding.typeIcon.setVisibility(mediaType == MediaItemType.MEDIA_TYPE_VIDEO || mediaType == MediaItemType.MEDIA_TYPE_SLIDER + ? View.VISIBLE : View.GONE); + final RoundingParams roundingParams = RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadiusSmall, dmRadiusSmall, dmRadiusSmall); + binding.preview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) + .setRoundingParams(roundingParams) + .build()); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(media); + binding.preview.setImageURI(thumbUrl); + } + + @Override + protected boolean canForward() { + return false; + } + + @Override + protected List getLongClickOptions() { + final ImmutableList.Builder builder = ImmutableList.builder(); + if (type != null && type.equals("reply")) { + builder.add(new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy_reply, item -> { + final DirectItemReelShare reelShare = item.getReelShare(); + if (reelShare == null) return null; + final String text = reelShare.getText(); + if (TextUtils.isEmpty(text)) return null; + Utils.copyText(itemView.getContext(), text); + return null; + })); + } + return builder.build(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java new file mode 100644 index 0000000..5efd347 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java @@ -0,0 +1,119 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.content.res.Resources; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.RoundingParams; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmStoryShareBinding; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; + +public class DirectItemStoryShareViewHolder extends DirectItemViewHolder { + + private final LayoutDmStoryShareBinding binding; + + public DirectItemStoryShareViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmStoryShareBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem item, final MessageDirection messageDirection) { + final Resources resources = itemView.getResources(); + int format = R.string.story_share; + final String reelType = item.getStoryShare().getReelType(); + if (reelType == null || item.getStoryShare().getMedia() == null) { + setExpiredStoryInfo(item); + return; + } + if (reelType.equals("highlight_reel")) { + format = R.string.story_share_highlight; + } + final User user = item.getStoryShare().getMedia().getUser(); + final String info = resources.getString(format, user != null ? user.getUsername() : ""); + binding.shareInfo.setText(info); + binding.text.setVisibility(View.GONE); + binding.ivMediaPreview.setController(null); + final DirectItemStoryShare storyShare = item.getStoryShare(); + if (storyShare == null) return; + setText(storyShare); + final Media media = storyShare.getMedia(); + setupPreview(messageDirection, media); + itemView.setOnClickListener(v -> openStory(storyShare)); + } + + private void setupPreview(final MessageDirection messageDirection, final Media storyShareMedia) { + final MediaItemType mediaType = storyShareMedia.getType(); + binding.typeIcon.setVisibility(mediaType == MediaItemType.MEDIA_TYPE_VIDEO ? View.VISIBLE : View.GONE); + final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING + ? RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius) + : RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); + binding.ivMediaPreview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) + .setRoundingParams(roundingParams) + .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) + .build()); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( + storyShareMedia.getOriginalHeight(), + storyShareMedia.getOriginalWidth(), + mediaImageMaxHeight, + mediaImageMaxWidth + ); + final ViewGroup.LayoutParams layoutParams = binding.ivMediaPreview.getLayoutParams(); + layoutParams.width = widthHeight.first; + layoutParams.height = widthHeight.second; + binding.ivMediaPreview.requestLayout(); + final String thumbUrl = ResponseBodyUtils.getThumbUrl(storyShareMedia); + binding.ivMediaPreview.setImageURI(thumbUrl); + } + + private void setText(final DirectItemStoryShare storyShare) { + final String text = storyShare.getText(); + if (!TextUtils.isEmpty(text)) { + binding.text.setText(text); + binding.text.setVisibility(View.VISIBLE); + return; + } + binding.text.setVisibility(View.GONE); + } + + private void setExpiredStoryInfo(final DirectItem item) { + binding.shareInfo.setText(item.getStoryShare().getTitle()); + binding.text.setVisibility(View.VISIBLE); + binding.text.setText(item.getStoryShare().getMessage()); + binding.ivMediaPreview.setVisibility(View.GONE); + binding.typeIcon.setVisibility(View.GONE); + } + + @Override + protected boolean canForward() { + return false; + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemTextViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemTextViewHolder.java new file mode 100644 index 0000000..d95817e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemTextViewHolder.java @@ -0,0 +1,57 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmTextBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +public class DirectItemTextViewHolder extends DirectItemViewHolder { + + private final LayoutDmTextBinding binding; + + public DirectItemTextViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmTextBinding binding, + final User currentUser, + final DirectThread thread, + @NonNull final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final String text = directItemModel.getText(); + if (text == null) return; + binding.tvMessage.setText(text); + setupRamboTextListeners(binding.tvMessage); + } + + @Override + protected boolean showBackground() { + return true; + } + + @Override + protected List getLongClickOptions() { + return ImmutableList.of( + new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy, item -> { + if (TextUtils.isEmpty(item.getText())) return null; + Utils.copyText(itemView.getContext(), item.getText()); + return null; + }) + ); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java new file mode 100644 index 0000000..4c56922 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java @@ -0,0 +1,90 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmActionLogBinding; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemVideoCallEvent; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.TextRange; +import awais.instagrabber.utils.TextUtils; + +public class DirectItemVideoCallEventViewHolder extends DirectItemViewHolder { + + private final LayoutDmActionLogBinding binding; + + public DirectItemVideoCallEventViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + final LayoutDmActionLogBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final DirectItemVideoCallEvent videoCallEvent = directItemModel.getVideoCallEvent(); + final String text = videoCallEvent.getDescription(); + final SpannableStringBuilder sb = new SpannableStringBuilder(text); + final List textAttributes = videoCallEvent.getTextAttributes(); + if (textAttributes != null && !textAttributes.isEmpty()) { + for (final TextRange textAttribute : textAttributes) { + if (!TextUtils.isEmpty(textAttribute.getColor())) { + final ForegroundColorSpan colorSpan = new ForegroundColorSpan(itemView.getResources().getColor(R.color.deep_orange_400)); + sb.setSpan(colorSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + if (!TextUtils.isEmpty(textAttribute.getIntent())) { + final ClickableSpan clickableSpan = new ClickableSpan() { + @Override + public void onClick(@NonNull final View widget) { + + } + }; + sb.setSpan(clickableSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + } + } + binding.tvMessage.setMaxLines(1); + binding.tvMessage.setText(sb); + } + + @Override + protected boolean allowMessageDirectionGravity() { + return false; + } + + @Override + protected boolean showUserDetailsInGroup() { + return false; + } + + @Override + protected boolean showMessageInfo() { + return false; + } + + @Override + protected boolean allowLongClick() { + return false; + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java new file mode 100644 index 0000000..de5b0c3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java @@ -0,0 +1,617 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.annotation.SuppressLint; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Point; +import android.graphics.drawable.Drawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewPropertyAnimator; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.FrameLayout; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.widget.ImageViewCompat; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.TransitionManager; + +import com.google.android.material.transition.MaterialFade; +import com.google.common.collect.ImmutableList; + +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemInternalLongClickListener; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.customviews.DirectItemFrameLayout; +import awais.instagrabber.customviews.RamboTextViewV2; +import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback.SwipeableViewHolder; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.models.enums.DirectItemType; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; +import awais.instagrabber.repositories.responses.directmessages.DirectItemReactions; +import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.DMUtils; +import awais.instagrabber.utils.DeepLinkParser; +import awais.instagrabber.utils.ResponseBodyUtils; + +public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder implements SwipeableViewHolder { + private static final String TAG = DirectItemViewHolder.class.getSimpleName(); + // private static final List THREAD_CHANGING_OPTIONS = ImmutableList.of(R.id.unsend); + + private final LayoutDmBaseBinding binding; + private final User currentUser; + private final DirectThread thread; + private final int groupMessageWidth; + private final List userIds; + private final DirectItemCallback callback; + private final int reactionAdjustMargin; + private final AccelerateDecelerateInterpolator accelerateDecelerateInterpolator = new AccelerateDecelerateInterpolator(); + + protected final int margin; + protected final int dmRadius; + protected final int dmRadiusSmall; + protected final int messageInfoPaddingSmall; + protected final int mediaImageMaxHeight; + protected final int windowWidth; + protected final int mediaImageMaxWidth; + protected final int reactionTranslationYType1; + protected final int reactionTranslationYType2; + + private boolean selected = false; + private DirectItemInternalLongClickListener longClickListener; + private DirectItem item; + private ViewPropertyAnimator shrinkGrowAnimator; + private MessageDirection messageDirection; + // private View.OnLayoutChangeListener layoutChangeListener; + + public DirectItemViewHolder(@NonNull final LayoutDmBaseBinding binding, + @NonNull final User currentUser, + @NonNull final DirectThread thread, + @NonNull final DirectItemCallback callback) { + super(binding.getRoot()); + this.binding = binding; + this.currentUser = currentUser; + this.thread = thread; + this.callback = callback; + userIds = thread.getUsers() + .stream() + .map(User::getPk) + .collect(Collectors.toList()); + binding.ivProfilePic.setVisibility(thread.isGroup() ? View.VISIBLE : View.GONE); + binding.ivProfilePic.setOnClickListener(null); + final Resources resources = itemView.getResources(); + margin = resources.getDimensionPixelSize(R.dimen.dm_message_item_margin); + final int avatarSize = resources.getDimensionPixelSize(R.dimen.dm_message_item_avatar_size); + dmRadius = resources.getDimensionPixelSize(R.dimen.dm_message_card_radius); + dmRadiusSmall = resources.getDimensionPixelSize(R.dimen.dm_message_card_radius_small); + messageInfoPaddingSmall = resources.getDimensionPixelSize(R.dimen.dm_message_info_padding_small); + windowWidth = resources.getDisplayMetrics().widthPixels; + mediaImageMaxHeight = resources.getDimensionPixelSize(R.dimen.dm_media_img_max_height); + reactionAdjustMargin = resources.getDimensionPixelSize(R.dimen.dm_reaction_adjust_margin); + final int groupWidthCorrection = avatarSize + messageInfoPaddingSmall * 3; + mediaImageMaxWidth = windowWidth - margin - (thread.isGroup() ? groupWidthCorrection : messageInfoPaddingSmall * 2); + // messageInfoPaddingSmall is used cuz it's also 4dp, 1 avatar margin + 2 paddings = 3 + groupMessageWidth = windowWidth - margin - groupWidthCorrection; + reactionTranslationYType1 = resources.getDimensionPixelSize(R.dimen.dm_reaction_translation_y_type_1); + reactionTranslationYType2 = resources.getDimensionPixelSize(R.dimen.dm_reaction_translation_y_type_2); + } + + public void bind(final int position, final DirectItem item) { + if (item == null) return; + this.item = item; + messageDirection = isSelf(item) ? MessageDirection.OUTGOING : MessageDirection.INCOMING; + // Asynchronous binding causes some weird behaviour + // itemView.post(() -> bindBase(item, messageDirection, position)); + // itemView.post(() -> bindItem(item, messageDirection)); + // itemView.post(() -> setupLongClickListener(position, messageDirection)); + bindBase(item, messageDirection, position); + bindItem(item, messageDirection); + setupLongClickListener(position, messageDirection); + } + + private void bindBase(@NonNull final DirectItem item, final MessageDirection messageDirection, final int position) { + final FrameLayout.LayoutParams containerLayoutParams = (FrameLayout.LayoutParams) binding.container.getLayoutParams(); + final DirectItemType itemType = item.getItemType(); + setMessageDirectionGravity(messageDirection, containerLayoutParams); + setGroupUserDetails(item, messageDirection); + setBackground(messageDirection); + setMessageInfo(item, messageDirection); + switch (itemType) { + case REEL_SHARE: + case STORY_SHARE: // i think they could have texts? +// containerLayoutParams.setMarginStart(0); +// containerLayoutParams.setMarginEnd(0); + case TEXT: + case LINK: + case UNKNOWN: + binding.messageInfo.setPadding(0, 0, dmRadius, dmRadiusSmall); + break; + default: + if (showMessageInfo()) { + binding.messageInfo.setPadding(0, 0, messageInfoPaddingSmall, dmRadiusSmall); + } + } + setupReply(item, messageDirection); + setReactions(item, position); + if (item.getRepliedToMessage() == null && item.getShowForwardAttribution()) { + setForwardInfo(messageDirection); + } + } + + private void setBackground(final MessageDirection messageDirection) { + if (showBackground()) { + binding.background.setBackgroundResource(messageDirection == MessageDirection.INCOMING ? R.drawable.bg_speech_bubble_incoming + : R.drawable.bg_speech_bubble_outgoing); + return; + } + binding.background.setBackgroundResource(0); + } + + private void setGroupUserDetails(final DirectItem item, final MessageDirection messageDirection) { + if (showUserDetailsInGroup()) { + binding.ivProfilePic.setVisibility(messageDirection == MessageDirection.INCOMING && thread.isGroup() ? View.VISIBLE : View.GONE); + binding.tvUsername.setVisibility(messageDirection == MessageDirection.INCOMING && thread.isGroup() ? View.VISIBLE : View.GONE); + if (messageDirection == MessageDirection.INCOMING && thread.isGroup()) { + final List allUsers = new LinkedList(thread.getUsers()); + allUsers.addAll(thread.getLeftUsers()); + final User user = getUser(item.getUserId(), allUsers); + if (user != null) { + binding.tvUsername.setText(user.getUsername()); + binding.ivProfilePic.setImageURI(user.getProfilePicUrl()); + } + ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) binding.chatMessageLayout.getLayoutParams(); + layoutParams.matchConstraintMaxWidth = groupMessageWidth; + binding.chatMessageLayout.setLayoutParams(layoutParams); + } + return; + } + binding.ivProfilePic.setVisibility(View.GONE); + binding.tvUsername.setVisibility(View.GONE); + } + + private void setMessageDirectionGravity(final MessageDirection messageDirection, + final FrameLayout.LayoutParams containerLayoutParams) { + if (allowMessageDirectionGravity()) { + containerLayoutParams.setMarginStart(messageDirection == MessageDirection.OUTGOING ? margin : 0); + containerLayoutParams.setMarginEnd(messageDirection == MessageDirection.INCOMING ? margin : 0); + containerLayoutParams.gravity = messageDirection == MessageDirection.INCOMING ? Gravity.START : Gravity.END; + return; + } + containerLayoutParams.gravity = Gravity.CENTER; + } + + private void setMessageInfo(@NonNull final DirectItem item, final MessageDirection messageDirection) { + if (showMessageInfo()) { + binding.messageInfo.setVisibility(View.VISIBLE); + binding.deliveryStatus.setVisibility(messageDirection == MessageDirection.OUTGOING ? View.VISIBLE : View.GONE); + if (item.getDate() != null) { + final DateTimeFormatter dateFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT); + binding.messageTime.setText(dateFormatter.format(item.getDate())); + } + if (messageDirection == MessageDirection.OUTGOING) { + if (item.isPending()) { + binding.deliveryStatus.setImageResource(R.drawable.ic_check_24); + } else { + final boolean read = DMUtils.isRead(item, + thread.getLastSeenAt(), + userIds + ); + binding.deliveryStatus.setImageResource(R.drawable.ic_check_all_24); + ImageViewCompat.setImageTintList( + binding.deliveryStatus, + ColorStateList.valueOf(itemView.getResources().getColor(read ? R.color.blue_500 : R.color.grey_500)) + ); + } + } + return; + } + binding.messageInfo.setVisibility(View.GONE); + } + + private void setupReply(final DirectItem item, final MessageDirection messageDirection) { + if (item.getRepliedToMessage() != null) { + final List allUsers = new LinkedList(thread.getUsers()); + allUsers.addAll(thread.getLeftUsers()); + setReply(item, messageDirection, allUsers); + } else { + binding.quoteLine.setVisibility(View.GONE); + binding.replyContainer.setVisibility(View.GONE); + binding.replyInfo.setVisibility(View.GONE); + } + } + + private void setReply(final DirectItem item, + final MessageDirection messageDirection, + final List users) { + final DirectItem replied = item.getRepliedToMessage(); + final DirectItemType itemType = replied.getItemType(); + final Resources resources = itemView.getResources(); + String text = null; + String url = null; + switch (itemType) { + case TEXT: + text = replied.getText(); + break; + case LINK: + text = replied.getLink().getText(); + break; + case PLACEHOLDER: + text = replied.getPlaceholder().getMessage(); + break; + case MEDIA: + url = ResponseBodyUtils.getThumbUrl(replied.getMedia()); + break; + case RAVEN_MEDIA: + url = ResponseBodyUtils.getThumbUrl(replied.getVisualMedia().getMedia()); + break; + case VOICE_MEDIA: + text = resources.getString(R.string.voice_message); + break; + case MEDIA_SHARE: + Media mediaShare = replied.getMediaShare(); + if (mediaShare.getType() == MediaItemType.MEDIA_TYPE_SLIDER) { + mediaShare = mediaShare.getCarouselMedia().get(0); + } + url = ResponseBodyUtils.getThumbUrl(mediaShare); + break; + case REEL_SHARE: + text = replied.getReelShare().getText(); + break; + // Below types cannot be replied to + // case LIKE: + // text = "❤️"; + // break; + // case PROFILE: + // text = "@" + replied.getProfile().getUsername(); + // break; + // case CLIP: + // url = ResponseBodyUtils.getThumbUrl(replied.getClip().getClip().getImageVersions2()); + // break; + // case FELIX_SHARE: + // url = ResponseBodyUtils.getThumbUrl(replied.getFelixShare().getVideo().getImageVersions2()); + // break; + // case STORY_SHARE: + // final DirectItemMedia media = replied.getStoryShare().getMedia(); + // if (media == null) break; + // url = ResponseBodyUtils.getThumbUrl(media.getImageVersions2()); + // break; + // case LOCATION + } + if (text == null && url == null) { + binding.quoteLine.setVisibility(View.GONE); + binding.replyContainer.setVisibility(View.GONE); + binding.replyInfo.setVisibility(View.GONE); + return; + } + setReplyGravity(messageDirection); + final String info = setReplyInfo(item, replied, users, resources); + binding.replyInfo.setVisibility(View.VISIBLE); + binding.replyInfo.setText(info); + binding.quoteLine.setVisibility(View.VISIBLE); + binding.replyContainer.setVisibility(View.VISIBLE); + if (url != null) { + binding.replyText.setVisibility(View.GONE); + binding.replyImage.setVisibility(View.VISIBLE); + binding.replyImage.setImageURI(url); + return; + } + binding.replyImage.setVisibility(View.GONE); + final Drawable background = binding.replyText.getBackground().mutate(); + background.setTint(replied.getUserId() != currentUser.getPk() + ? resources.getColor(R.color.grey_600) + : resources.getColor(R.color.deep_purple_400)); + binding.replyText.setBackgroundDrawable(background); + binding.replyText.setVisibility(View.VISIBLE); + binding.replyText.setText(text); + } + + private String setReplyInfo(final DirectItem item, + final DirectItem replied, + final List users, + final Resources resources) { + final long repliedToUserId = replied.getUserId(); + if (repliedToUserId == item.getUserId() && item.getUserId() == currentUser.getPk()) { + // User replied to own message + return resources.getString(R.string.replied_to_yourself); + } + if (repliedToUserId == item.getUserId()) { + // opposite user replied to their own message + return resources.getString(R.string.replied_to_themself); + } + final User user = getUser(repliedToUserId, users); + final String repliedToUsername = user != null ? user.getUsername() : ""; + if (item.getUserId() == currentUser.getPk()) { + return thread.isGroup() + ? resources.getString(R.string.replied_you_group, repliedToUsername) + : resources.getString(R.string.replied_you); + } + if (repliedToUserId == currentUser.getPk()) { + return resources.getString(R.string.replied_to_you); + } + return resources.getString(R.string.replied_group, repliedToUsername); + } + + private void setForwardInfo(final MessageDirection direction) { + binding.replyInfo.setVisibility(View.VISIBLE); + binding.replyInfo.setText(direction == MessageDirection.OUTGOING ? R.string.forward_outgoing : R.string.forward_incoming); + } + + private void setReplyGravity(final MessageDirection messageDirection) { + final boolean isIncoming = messageDirection == MessageDirection.INCOMING; + final ConstraintLayout.LayoutParams quoteLineLayoutParams = (ConstraintLayout.LayoutParams) binding.quoteLine.getLayoutParams(); + final ConstraintLayout.LayoutParams replyContainerLayoutParams = (ConstraintLayout.LayoutParams) binding.replyContainer.getLayoutParams(); + final ConstraintLayout.LayoutParams replyInfoLayoutParams = (ConstraintLayout.LayoutParams) binding.replyInfo.getLayoutParams(); + final int profilePicId = binding.ivProfilePic.getId(); + final int replyContainerId = binding.replyContainer.getId(); + final int quoteLineId = binding.quoteLine.getId(); + quoteLineLayoutParams.startToEnd = isIncoming ? profilePicId : replyContainerId; + quoteLineLayoutParams.endToStart = isIncoming ? replyContainerId : ConstraintLayout.LayoutParams.UNSET; + quoteLineLayoutParams.endToEnd = isIncoming ? ConstraintLayout.LayoutParams.UNSET : ConstraintLayout.LayoutParams.PARENT_ID; + replyContainerLayoutParams.startToEnd = isIncoming ? quoteLineId : profilePicId; + replyContainerLayoutParams.endToEnd = isIncoming ? ConstraintLayout.LayoutParams.PARENT_ID : ConstraintLayout.LayoutParams.UNSET; + replyContainerLayoutParams.endToStart = isIncoming ? ConstraintLayout.LayoutParams.UNSET : quoteLineId; + replyInfoLayoutParams.startToEnd = isIncoming ? quoteLineId : ConstraintLayout.LayoutParams.UNSET; + replyInfoLayoutParams.endToStart = isIncoming ? ConstraintLayout.LayoutParams.UNSET : quoteLineId; + } + + private void setReactions(final DirectItem item, final int position) { + binding.getRoot().post(() -> { + MaterialFade materialFade = new MaterialFade(); + materialFade.addTarget(binding.emojis); + TransitionManager.beginDelayedTransition(binding.getRoot(), materialFade); + final DirectItemReactions reactions = item.getReactions(); + final List emojis = reactions != null ? reactions.getEmojis() : null; + if (emojis == null || emojis.isEmpty()) { + binding.container.setPadding(messageInfoPaddingSmall, messageInfoPaddingSmall, messageInfoPaddingSmall, 0); + binding.reactionsWrapper.setVisibility(View.GONE); + return; + } + binding.reactionsWrapper.setVisibility(View.VISIBLE); + binding.reactionsWrapper.setTranslationY(getReactionsTranslationY()); + binding.container.setPadding(messageInfoPaddingSmall, messageInfoPaddingSmall, messageInfoPaddingSmall, reactionAdjustMargin); + binding.emojis.setEmojis(emojis.stream() + .map(DirectItemEmojiReaction::getEmoji) + .collect(Collectors.toList())); + // binding.emojis.setEmojis(ImmutableList.of("😣", + // "😖", + // "😫", + // "😩", + // "🥺", + // "😢", + // "😭", + // "😤", + // "😠", + // "😡", + // "🤬")); + binding.emojis.setOnClickListener(v -> callback.onReactionClick(item, position)); + // final List reactedUsers = emojis.stream() + // .map(DirectItemEmojiReaction::getSenderId) + // .distinct() + // .map(userId -> getUser(userId, users)) + // .collect(Collectors.toList()); + // for (final DirectUser user : reactedUsers) { + // if (user == null) continue; + // final ProfilePicView profilePicView = new ProfilePicView(itemView.getContext()); + // profilePicView.setSize(ProfilePicView.Size.TINY); + // profilePicView.setImageURI(user.getProfilePicUrl()); + // binding.reactions.addView(profilePicView); + // } + }); + } + + protected boolean isSelf(final DirectItem directItem) { + return directItem.getUserId() == currentUser.getPk(); + } + + public void setItemView(final View view) { + this.binding.message.addView(view); + } + + public abstract void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection); + + @Nullable + protected User getUser(final long userId, final List users) { + if (userId == currentUser.getPk()) { + return currentUser; + } + if (users == null) return null; + for (final User user : users) { + if (userId != user.getPk()) continue; + return user; + } + return null; + } + + protected boolean allowMessageDirectionGravity() { + return true; + } + + protected boolean showUserDetailsInGroup() { + return true; + } + + protected boolean showBackground() { + return false; + } + + protected boolean showMessageInfo() { + return true; + } + + protected boolean allowLongClick() { + return true; + } + + protected boolean allowReaction() { + return true; + } + + protected boolean canForward() { + return true; + } + + protected List getLongClickOptions() { + return null; + } + + protected int getReactionsTranslationY() { + return reactionTranslationYType1; + } + + @CallSuper + public void cleanup() { + // if (layoutChangeListener != null) { + // binding.container.removeOnLayoutChangeListener(layoutChangeListener); + // } + } + + protected void setupRamboTextListeners(@NonNull final RamboTextViewV2 textView) { + textView.addOnHashtagListener(autoLinkItem -> callback.onHashtagClick(autoLinkItem.getOriginalText().trim())); + textView.addOnMentionClickListener(autoLinkItem -> openProfile(autoLinkItem.getOriginalText().trim())); + textView.addOnEmailClickListener(autoLinkItem -> callback.onEmailClick(autoLinkItem.getOriginalText().trim())); + textView.addOnURLClickListener(autoLinkItem -> openURL(autoLinkItem.getOriginalText().trim())); + } + + protected void openProfile(final String username) { + callback.onMentionClick(username); + } + + protected void openLocation(final long locationId) { + callback.onLocationClick(locationId); + } + + protected void openURL(final String url) { + callback.onURLClick(url); + } + + protected void openMedia(final Media media, final int index) { + callback.onMediaClick(media, index); + } + + protected void openStory(final DirectItemStoryShare storyShare) { + callback.onStoryClick(storyShare); + } + + protected void handleDeepLink(final String deepLinkText) { + if (deepLinkText == null) return; + final DeepLinkParser.DeepLink deepLink = DeepLinkParser.parse(deepLinkText); + if (deepLink == null) return; + switch (deepLink.getType()) { + case USER: + callback.onMentionClick(deepLink.getValue()); + break; + } + } + + @SuppressLint("ClickableViewAccessibility") + private void setupLongClickListener(final int position, final MessageDirection messageDirection) { + if (!allowLongClick()) return; + binding.getRoot().setOnItemLongClickListener(new DirectItemFrameLayout.OnItemLongClickListener() { + @Override + public void onLongClickStart(final View view) { + itemView.post(() -> shrink()); + } + + @Override + public void onLongClickCancel(final View view) { + itemView.post(() -> grow()); + } + + @Override + public void onLongClick(final View view, final float x, final float y) { + // if (longClickListener == null) return false; + // longClickListener.onLongClick(position, this); + itemView.post(() -> grow()); + setSelected(true); + showLongClickOptions(new Point((int) x, (int) y), messageDirection); + } + }); + } + + private void showLongClickOptions(final Point location, final MessageDirection messageDirection) { + final List longClickOptions = getLongClickOptions(); + final ImmutableList.Builder builder = ImmutableList.builder(); + if (longClickOptions != null) { + builder.addAll(longClickOptions); + } + if (canForward()) { + builder.add(new DirectItemContextMenu.MenuItem(R.id.forward, R.string.forward)); + } + if (thread.getInputMode() != 1 && messageDirection == MessageDirection.OUTGOING) { + builder.add(new DirectItemContextMenu.MenuItem(R.id.unsend, R.string.dms_inbox_unsend)); + } + final boolean showReactions = thread.getInputMode() != 1 && allowReaction(); + final ImmutableList menuItems = builder.build(); + if (!showReactions && menuItems.isEmpty()) return; + final DirectItemContextMenu menu = new DirectItemContextMenu(itemView.getContext(), showReactions, menuItems); + menu.setOnDismissListener(() -> setSelected(false)); + menu.setOnReactionClickListener(emoji -> callback.onReaction(item, emoji)); + menu.setOnOptionSelectListener((itemId, cb) -> callback.onOptionSelect(item, itemId, cb)); + menu.setOnAddReactionListener(() -> { + menu.dismiss(); + itemView.postDelayed(() -> callback.onAddReactionListener(item), 300); + }); + menu.show(itemView, location); + } + + public void setLongClickListener(final DirectItemInternalLongClickListener longClickListener) { + this.longClickListener = longClickListener; + } + + public void setSelected(final boolean selected) { + this.selected = selected; + } + + private void shrink() { + if (shrinkGrowAnimator != null) { + shrinkGrowAnimator.cancel(); + } + shrinkGrowAnimator = itemView.animate() + .scaleX(0.8f) + .scaleY(0.8f) + .setInterpolator(accelerateDecelerateInterpolator) + .setDuration(ViewConfiguration.getLongPressTimeout() - ViewConfiguration.getTapTimeout()); + shrinkGrowAnimator.start(); + } + + private void grow() { + if (shrinkGrowAnimator != null) { + shrinkGrowAnimator.cancel(); + } + shrinkGrowAnimator = itemView.animate() + .scaleX(1f) + .scaleY(1f) + .setInterpolator(accelerateDecelerateInterpolator) + .setDuration(200) + .withEndAction(() -> shrinkGrowAnimator = null); + shrinkGrowAnimator.start(); + } + + @Override + public int getSwipeDirection() { + if (item == null || messageDirection == null) return ItemTouchHelper.ACTION_STATE_IDLE; + return messageDirection == MessageDirection.OUTGOING ? ItemTouchHelper.START : ItemTouchHelper.END; + } + + public enum MessageDirection { + INCOMING, + OUTGOING + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java new file mode 100644 index 0000000..255ccea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java @@ -0,0 +1,199 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.os.Handler; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Floats; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.customviews.DirectItemContextMenu; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.databinding.LayoutDmVoiceMediaBinding; +import awais.instagrabber.repositories.responses.Audio; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemVoiceMedia; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.utils.TextUtils; + +import static com.google.android.exoplayer2.C.TIME_UNSET; + +public class DirectItemVoiceMediaViewHolder extends DirectItemViewHolder { + private static final String TAG = "DirectItemVoiceMediaVH"; + + private final LayoutDmVoiceMediaBinding binding; + private final DefaultDataSourceFactory dataSourceFactory; + private SimpleExoPlayer player; + private Handler handler; + private Runnable positionChecker; + private Player.EventListener listener; + + public DirectItemVoiceMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmVoiceMediaBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + this.dataSourceFactory = new DefaultDataSourceFactory(binding.getRoot().getContext(), "instagram"); + setItemView(binding.getRoot()); + binding.voiceMedia.getLayoutParams().width = mediaImageMaxWidth; + } + + @Override + public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { + final DirectItemVoiceMedia voiceMedia = directItemModel.getVoiceMedia(); + if (voiceMedia == null) return; + final Media media = voiceMedia.getMedia(); + if (media == null) return; + final Audio audio = media.getAudio(); + if (audio == null) return; + final List waveformData = audio.getWaveformData(); + binding.waveformSeekBar.setSample(Floats.toArray(waveformData)); + binding.waveformSeekBar.setEnabled(false); + final String text = String.format("%s/%s", TextUtils.millisToTimeString(0), TextUtils.millisToTimeString(audio.getDuration())); + binding.duration.setText(text); + final AudioItemState audioItemState = new AudioItemState(); + player = new SimpleExoPlayer.Builder(itemView.getContext()).build(); + player.setVolume(1); + player.setPlayWhenReady(true); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + handler = new Handler(); + final long initialDelay = 0; + final long recurringDelay = 60; + positionChecker = new Runnable() { + @Override + public void run() { + if (handler != null) { + handler.removeCallbacks(this); + } + if (player == null) return; + final long currentPosition = player.getCurrentPosition(); + final long duration = player.getDuration(); + // Log.d(TAG, "currentPosition: " + currentPosition + ", duration: " + duration); + if (duration == TIME_UNSET) return; + // final float progress = ((float) currentPosition / duration /* * 100 */); + final int progress = (int) ((float) currentPosition / duration * 100); + // Log.d(TAG, "progress: " + progress); + final String text = String.format("%s/%s", TextUtils.millisToTimeString(currentPosition), TextUtils.millisToTimeString(duration)); + binding.duration.setText(text); + binding.waveformSeekBar.setProgress(progress); + if (handler != null) { + handler.postDelayed(this, recurringDelay); + } + } + }; + player.addListener(listener = new Player.EventListener() { + @Override + public void onPlaybackStateChanged(final int state) { + if (!audioItemState.isPrepared() && state == Player.STATE_READY) { + binding.playPause.setIconResource(R.drawable.ic_round_pause_24); + audioItemState.setPrepared(true); + binding.playPause.setVisibility(View.VISIBLE); + binding.progressBar.setVisibility(View.GONE); + if (handler != null) { + handler.postDelayed(positionChecker, initialDelay); + } + return; + } + if (state == Player.STATE_ENDED) { + // binding.waveformSeekBar.setProgressInPercentage(0); + binding.waveformSeekBar.setProgress(0); + binding.playPause.setIconResource(R.drawable.ic_round_play_arrow_24); + if (handler != null) { + handler.removeCallbacks(positionChecker); + } + } + } + + @Override + public void onPlayerError(final ExoPlaybackException error) { + Log.e(TAG, "onPlayerError: ", error); + } + }); + final ProgressiveMediaSource.Factory sourceFactory = new ProgressiveMediaSource.Factory(dataSourceFactory); + final MediaItem mediaItem = MediaItem.fromUri(audio.getAudioSrc()); + final ProgressiveMediaSource mediaSource = sourceFactory.createMediaSource(mediaItem); + player.setMediaSource(mediaSource); + binding.playPause.setOnClickListener(v -> { + if (player == null) return; + if (!audioItemState.isPrepared()) { + player.prepare(); + binding.playPause.setVisibility(View.GONE); + binding.progressBar.setVisibility(View.VISIBLE); + return; + } + if (player.isPlaying()) { + binding.playPause.setIconResource(R.drawable.ic_round_play_arrow_24); + player.pause(); + return; + } + binding.playPause.setIconResource(R.drawable.ic_round_pause_24); + if (player.getPlaybackState() == Player.STATE_ENDED) { + player.seekTo(0); + if (handler != null) { + handler.postDelayed(positionChecker, initialDelay); + } + } + binding.waveformSeekBar.setEnabled(true); + player.play(); + }); + } + + @Override + public void cleanup() { + super.cleanup(); + if (handler != null && positionChecker != null) { + handler.removeCallbacks(positionChecker); + handler = null; + positionChecker = null; + } + if (player != null) { + player.release(); + if (listener != null) { + player.removeListener(listener); + } + player = null; + } + } + + @Override + protected boolean canForward() { + return false; + } + + @Override + protected List getLongClickOptions() { + return ImmutableList.of( + new DirectItemContextMenu.MenuItem(R.id.download, R.string.action_download) + ); + } + + private static class AudioItemState { + private boolean prepared; + + private AudioItemState() {} + + public boolean isPrepared() { + return prepared; + } + + public void setPrepared(final boolean prepared) { + this.prepared = prepared; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemXmaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemXmaViewHolder.java new file mode 100644 index 0000000..d6420bd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemXmaViewHolder.java @@ -0,0 +1,70 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; + +import com.facebook.drawee.backends.pipeline.Fresco; + +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.databinding.LayoutDmAnimatedMediaBinding; +import awais.instagrabber.databinding.LayoutDmBaseBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemXma; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.XmaUrlInfo; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; + +public class DirectItemXmaViewHolder extends DirectItemViewHolder { + + private final LayoutDmAnimatedMediaBinding binding; + + public DirectItemXmaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, + @NonNull final LayoutDmAnimatedMediaBinding binding, + final User currentUser, + final DirectThread thread, + final DirectItemCallback callback) { + super(baseBinding, currentUser, thread, callback); + this.binding = binding; + setItemView(binding.getRoot()); + } + + @Override + public void bindItem(final DirectItem item, final MessageDirection messageDirection) { + final DirectItemXma xma = item.getXma(); + final XmaUrlInfo playableUrlInfo = xma.getPlayableUrlInfo(); + final XmaUrlInfo previewUrlInfo = xma.getPreviewUrlInfo(); + if (playableUrlInfo == null && previewUrlInfo == null) { + binding.ivAnimatedMessage.setController(null); + return; + } + final XmaUrlInfo urlInfo = playableUrlInfo != null ? playableUrlInfo : previewUrlInfo; + final String url = urlInfo.getUrl(); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( + urlInfo.getHeight(), + urlInfo.getWidth(), + mediaImageMaxHeight, + mediaImageMaxWidth + ); + binding.ivAnimatedMessage.setVisibility(View.VISIBLE); + final ViewGroup.LayoutParams layoutParams = binding.ivAnimatedMessage.getLayoutParams(); + final int width = widthHeight.first; + final int height = widthHeight.second; + layoutParams.width = width; + layoutParams.height = height; + binding.ivAnimatedMessage.requestLayout(); + binding.ivAnimatedMessage.setController(Fresco.newDraweeControllerBuilder() + .setUri(url) + .setAutoPlayAnimations(true) + .build()); + } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectPendingUserViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectPendingUserViewHolder.java new file mode 100644 index 0000000..bc98282 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectPendingUserViewHolder.java @@ -0,0 +1,89 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.graphics.drawable.Drawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUser; +import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUserCallback; +import awais.instagrabber.customviews.VerticalImageSpan; +import awais.instagrabber.databinding.LayoutDmPendingUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.Utils; + +public class DirectPendingUserViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = DirectPendingUserViewHolder.class.getSimpleName(); + + private final LayoutDmPendingUserItemBinding binding; + private final PendingUserCallback callback; + private final int drawableSize; + + private VerticalImageSpan verifiedSpan; + + public DirectPendingUserViewHolder(@NonNull final LayoutDmPendingUserItemBinding binding, + final PendingUserCallback callback) { + super(binding.getRoot()); + this.binding = binding; + this.callback = callback; + drawableSize = Utils.convertDpToPx(24); + } + + public void bind(final int position, final PendingUser pendingUser) { + if (pendingUser == null) return; + binding.getRoot().setOnClickListener(v -> { + if (callback == null) return; + callback.onClick(position, pendingUser); + }); + setUsername(pendingUser); + binding.requester.setText(itemView.getResources().getString(R.string.added_by, pendingUser.getRequester())); + binding.profilePic.setImageURI(pendingUser.getUser().getProfilePicUrl()); + if (pendingUser.isInProgress()) { + binding.approve.setVisibility(View.GONE); + binding.deny.setVisibility(View.GONE); + binding.progress.setVisibility(View.VISIBLE); + return; + } + binding.approve.setVisibility(View.VISIBLE); + binding.deny.setVisibility(View.VISIBLE); + binding.progress.setVisibility(View.GONE); + binding.approve.setOnClickListener(v -> { + if (callback == null) return; + callback.onApprove(position, pendingUser); + }); + binding.deny.setOnClickListener(v -> { + if (callback == null) return; + callback.onDeny(position, pendingUser); + }); + } + + private void setUsername(final PendingUser pendingUser) { + final User user = pendingUser.getUser(); + final SpannableStringBuilder sb = new SpannableStringBuilder(user.getUsername()); + if (user.isVerified()) { + if (verifiedSpan == null) { + final Drawable verifiedDrawable = AppCompatResources.getDrawable(itemView.getContext(), R.drawable.verified); + if (verifiedDrawable != null) { + final Drawable drawable = verifiedDrawable.mutate(); + drawable.setBounds(0, 0, drawableSize, drawableSize); + verifiedSpan = new VerticalImageSpan(drawable); + } + } + try { + if (verifiedSpan != null) { + sb.append(" "); + sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + Log.e(TAG, "bind: ", e); + } + } + binding.username.setText(sb); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectReactionViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectReactionViewHolder.java new file mode 100644 index 0000000..84ee907 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectReactionViewHolder.java @@ -0,0 +1,71 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectReactionsAdapter.OnReactionClickListener; +import awais.instagrabber.customviews.emoji.Emoji; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; +import awais.instagrabber.utils.emoji.EmojiParser; + +public class DirectReactionViewHolder extends RecyclerView.ViewHolder { + private final LayoutDmUserItemBinding binding; + private final long viewerId; + private final String itemId; + private final OnReactionClickListener onReactionClickListener; + private final EmojiParser emojiParser; + + public DirectReactionViewHolder(final LayoutDmUserItemBinding binding, + final long viewerId, + final String itemId, + final OnReactionClickListener onReactionClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.viewerId = viewerId; + this.itemId = itemId; + this.onReactionClickListener = onReactionClickListener; + binding.info.setVisibility(View.GONE); + binding.secondaryImage.setVisibility(View.VISIBLE); + emojiParser = EmojiParser.Companion.getInstance(itemView.getContext()); + } + + public void bind(final DirectItemEmojiReaction reaction, + @Nullable final User user) { + itemView.setOnClickListener(v -> { + if (onReactionClickListener == null) return; + onReactionClickListener.onReactionClick(itemId, reaction); + }); + setUser(user); + setReaction(reaction); + } + + private void setReaction(final DirectItemEmojiReaction reaction) { + final Emoji emoji = emojiParser.getEmoji(reaction.getEmoji()); + if (emoji == null) { + binding.secondaryImage.setImageDrawable(null); + return; + } + binding.secondaryImage.setImageDrawable(emoji.getDrawable()); + } + + private void setUser(final User user) { + if (user == null) { + binding.fullName.setText(""); + binding.username.setText(""); + binding.profilePic.setImageURI((String) null); + return; + } + binding.fullName.setText(user.getFullName()); + if (user.getPk() == viewerId) { + binding.username.setText(R.string.tap_to_remove); + } else { + binding.username.setText(user.getUsername()); + } + binding.profilePic.setImageURI(user.getProfilePicUrl()); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectUserViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectUserViewHolder.java new file mode 100644 index 0000000..695ae70 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectUserViewHolder.java @@ -0,0 +1,102 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.graphics.drawable.Drawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; +import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserLongClickListener; +import awais.instagrabber.customviews.VerticalImageSpan; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.Utils; + +public class DirectUserViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = DirectUserViewHolder.class.getSimpleName(); + + private final LayoutDmUserItemBinding binding; + private final OnDirectUserClickListener onClickListener; + private final OnDirectUserLongClickListener onLongClickListener; + private final int drawableSize; + + private VerticalImageSpan verifiedSpan; + + public DirectUserViewHolder(@NonNull final LayoutDmUserItemBinding binding, + final OnDirectUserClickListener onClickListener, + final OnDirectUserLongClickListener onLongClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onClickListener = onClickListener; + this.onLongClickListener = onLongClickListener; + drawableSize = Utils.convertDpToPx(24); + } + + public void bind(final int position, + final User user, + final boolean isAdmin, + final boolean isInviter, + final boolean showSelection, + final boolean isSelected) { + if (user == null) return; + binding.getRoot().setOnClickListener(v -> { + if (onClickListener == null) return; + onClickListener.onClick(position, user, isSelected); + }); + binding.getRoot().setOnLongClickListener(v -> { + if (onLongClickListener == null) return false; + return onLongClickListener.onLongClick(position, user); + }); + setFullName(user); + binding.username.setText(user.getUsername()); + binding.profilePic.setImageURI(user.getProfilePicUrl()); + setInfo(isAdmin, isInviter); + setSelection(showSelection, isSelected); + } + + private void setFullName(final User user) { + final SpannableStringBuilder sb = new SpannableStringBuilder(user.getFullName()); + if (user.isVerified()) { + if (verifiedSpan == null) { + final Drawable verifiedDrawable = AppCompatResources.getDrawable(itemView.getContext(), R.drawable.verified); + if (verifiedDrawable != null) { + final Drawable drawable = verifiedDrawable.mutate(); + drawable.setBounds(0, 0, drawableSize, drawableSize); + verifiedSpan = new VerticalImageSpan(drawable); + } + } + try { + if (verifiedSpan != null) { + sb.append(" "); + sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + Log.e(TAG, "bind: ", e); + } + } + binding.fullName.setText(sb); + } + + private void setInfo(final boolean isAdmin, final boolean isInviter) { + if (!isAdmin && !isInviter) { + binding.info.setVisibility(View.GONE); + return; + } + if (isAdmin) { + binding.info.setText(R.string.admin); + return; + } + binding.info.setText(R.string.inviter); + } + + private void setSelection(final boolean showSelection, final boolean isSelected) { + binding.select.setVisibility(showSelection ? View.VISIBLE : View.GONE); + binding.getRoot().setSelected(isSelected); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java new file mode 100644 index 0000000..eb39f40 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java @@ -0,0 +1,89 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.content.res.Resources; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.UserSearchResultsAdapter.OnRecipientClickListener; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; + +public class RecipientThreadViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = RecipientThreadViewHolder.class.getSimpleName(); + + private final LayoutDmUserItemBinding binding; + private final OnRecipientClickListener onThreadClickListener; + private final float translateAmount; + + public RecipientThreadViewHolder(@NonNull final LayoutDmUserItemBinding binding, + final OnRecipientClickListener onThreadClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onThreadClickListener = onThreadClickListener; + binding.info.setVisibility(View.GONE); + final Resources resources = itemView.getResources(); + final int avatarSize = resources.getDimensionPixelSize(R.dimen.dm_inbox_avatar_size); + translateAmount = ((float) avatarSize) / 7; + } + + public void bind(final int position, + final DirectThread thread, + final boolean showSelection, + final boolean isSelected) { + if (thread == null || thread.getUsers().size() == 0) return; + binding.getRoot().setOnClickListener(v -> { + if (onThreadClickListener == null) return; + onThreadClickListener.onClick(position, RankedRecipient.of(thread), isSelected); + }); + binding.fullName.setText(thread.getThreadTitle()); + setUsername(thread); + setProfilePic(thread); + setSelection(showSelection, isSelected); + } + + private void setProfilePic(final DirectThread thread) { + final List users = thread.getUsers(); + binding.profilePic.setImageURI(users.get(0).getProfilePicUrl()); + binding.profilePic.setScaleX(1); + binding.profilePic.setScaleY(1); + binding.profilePic.setTranslationX(0); + binding.profilePic.setTranslationY(0); + if (users.size() > 1) { + binding.profilePic2.setVisibility(View.VISIBLE); + binding.profilePic2.setImageURI(users.get(1).getProfilePicUrl()); + binding.profilePic2.setTranslationX(translateAmount); + binding.profilePic2.setTranslationY(translateAmount); + final float scaleAmount = 0.75f; + binding.profilePic2.setScaleX(scaleAmount); + binding.profilePic2.setScaleY(scaleAmount); + binding.profilePic.setScaleX(scaleAmount); + binding.profilePic.setScaleY(scaleAmount); + binding.profilePic.setTranslationX(-translateAmount); + binding.profilePic.setTranslationY(-translateAmount); + return; + } + binding.profilePic2.setVisibility(View.GONE); + } + + private void setUsername(final DirectThread thread) { + if (thread.isGroup()) { + binding.username.setVisibility(View.GONE); + return; + } + binding.username.setVisibility(View.VISIBLE); + // for a non-group thread, the thread title is the username so set the full name in the username text view + binding.username.setText(thread.getUsers().get(0).getFullName()); + } + + private void setSelection(final boolean showSelection, final boolean isSelected) { + binding.select.setVisibility(showSelection ? View.VISIBLE : View.GONE); + binding.getRoot().setSelected(isSelected); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedItemViewHolder.java new file mode 100644 index 0000000..46320eb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedItemViewHolder.java @@ -0,0 +1,182 @@ +package awais.instagrabber.adapters.viewholder.feed; + +import android.graphics.drawable.Drawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.transition.TransitionManager; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.customviews.VerticalImageSpan; +import awais.instagrabber.databinding.ItemFeedTopBinding; +import awais.instagrabber.databinding.LayoutPostViewBottomBinding; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Caption; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static android.text.TextUtils.TruncateAt.END; + +public abstract class FeedItemViewHolder extends RecyclerView.ViewHolder { + public static final int MAX_LINES_COLLAPSED = 5; + private final ItemFeedTopBinding topBinding; + private final LayoutPostViewBottomBinding bottomBinding; + private final ViewGroup bottomFrame; + private final FeedAdapterV2.FeedItemCallback feedItemCallback; + + public FeedItemViewHolder(@NonNull final ViewGroup root, + final FeedAdapterV2.FeedItemCallback feedItemCallback) { + super(root); + this.bottomFrame = root; + this.topBinding = ItemFeedTopBinding.bind(root); + this.bottomBinding = LayoutPostViewBottomBinding.bind(root); + this.feedItemCallback = feedItemCallback; + } + + public void bind(final Media media) { + if (media == null) { + return; + } + setupProfilePic(media); + bottomBinding.date.setText(media.getDate()); + setupComments(media); + setupCaption(media); + setupActions(media); + if (media.getType() != MediaItemType.MEDIA_TYPE_SLIDER) { + bottomBinding.download.setOnClickListener(v -> + feedItemCallback.onDownloadClick(media, -1, null) + ); + } + bindItem(media); + bottomFrame.post(() -> setupLocation(media)); + } + + private void setupComments(@NonNull final Media feedModel) { + final long commentsCount = feedModel.getCommentCount(); + bottomBinding.commentsCount.setText(String.valueOf(commentsCount)); + bottomBinding.comment.setOnClickListener(v -> feedItemCallback.onCommentsClick(feedModel)); + } + + private void setupProfilePic(@NonNull final Media media) { + final User user = media.getUser(); + if (user == null) { + topBinding.profilePic.setVisibility(View.GONE); + topBinding.title.setVisibility(View.GONE); + topBinding.subtitle.setVisibility(View.GONE); + return; + } + topBinding.profilePic.setOnClickListener(v -> feedItemCallback.onProfilePicClick(media)); + topBinding.profilePic.setImageURI(user.getProfilePicUrl()); + setupTitle(media); + } + + private void setupTitle(@NonNull final Media media) { + // final int titleLen = profileModel.getUsername().length() + 1; + // final SpannableString spannableString = new SpannableString(); + // spannableString.setSpan(new CommentMentionClickSpan(), 0, titleLen, 0); + final User user = media.getUser(); + if (user == null) return; + setUsername(user); + topBinding.title.setOnClickListener(v -> feedItemCallback.onNameClick(media)); + final String fullName = user.getFullName(); + if (TextUtils.isEmpty(fullName)) { + topBinding.subtitle.setVisibility(View.GONE); + } else { + topBinding.subtitle.setVisibility(View.VISIBLE); + topBinding.subtitle.setText(fullName); + } + topBinding.subtitle.setOnClickListener(v -> feedItemCallback.onNameClick(media)); + } + + private void setupCaption(final Media media) { + bottomBinding.caption.clearOnMentionClickListeners(); + bottomBinding.caption.clearOnHashtagClickListeners(); + bottomBinding.caption.clearOnURLClickListeners(); + bottomBinding.caption.clearOnEmailClickListeners(); + final Caption caption = media.getCaption(); + if (caption == null) { + bottomBinding.caption.setVisibility(View.GONE); + return; + } + final CharSequence postCaption = caption.getText(); + final boolean captionEmpty = TextUtils.isEmpty(postCaption); + bottomBinding.caption.setVisibility(captionEmpty ? View.GONE : View.VISIBLE); + if (captionEmpty) return; + bottomBinding.caption.setText(postCaption); + bottomBinding.caption.setMaxLines(MAX_LINES_COLLAPSED); + bottomBinding.caption.setEllipsize(END); + bottomBinding.caption.setOnClickListener(v -> bottomFrame.post(() -> { + TransitionManager.beginDelayedTransition(bottomFrame); + if (bottomBinding.caption.getMaxLines() == MAX_LINES_COLLAPSED) { + bottomBinding.caption.setMaxLines(Integer.MAX_VALUE); + bottomBinding.caption.setEllipsize(null); + return; + } + bottomBinding.caption.setMaxLines(MAX_LINES_COLLAPSED); + bottomBinding.caption.setEllipsize(END); + })); + bottomBinding.caption.addOnMentionClickListener(autoLinkItem -> feedItemCallback.onMentionClick(autoLinkItem.getOriginalText())); + bottomBinding.caption.addOnHashtagListener(autoLinkItem -> feedItemCallback.onHashtagClick(autoLinkItem.getOriginalText())); + bottomBinding.caption.addOnEmailClickListener(autoLinkItem -> feedItemCallback.onEmailClick(autoLinkItem.getOriginalText())); + bottomBinding.caption.addOnURLClickListener(autoLinkItem -> feedItemCallback.onURLClick(autoLinkItem.getOriginalText())); + } + + private void setupLocation(@NonNull final Media media) { + final Location location = media.getLocation(); + if (location == null) { + topBinding.location.setVisibility(View.GONE); + } else { + final String locationName = location.getName(); + if (TextUtils.isEmpty(locationName)) { + topBinding.location.setVisibility(View.GONE); + } else { + topBinding.location.setVisibility(View.VISIBLE); + topBinding.location.setText(locationName); + topBinding.location.setOnClickListener(v -> feedItemCallback.onLocationClick(media)); + } + } + } + + private void setupActions(@NonNull final Media media) { + // temporary - to be set up later + bottomBinding.like.setVisibility(View.GONE); + bottomBinding.save.setVisibility(View.GONE); + bottomBinding.translate.setVisibility(View.GONE); + bottomBinding.share.setVisibility(View.GONE); + } + + private void setUsername(final User user) { + final SpannableStringBuilder sb = new SpannableStringBuilder(user.getUsername()); + final int drawableSize = Utils.convertDpToPx(24); + if (user.isVerified()) { + final Drawable verifiedDrawable = itemView.getResources().getDrawable(R.drawable.verified); + VerticalImageSpan verifiedSpan = null; + if (verifiedDrawable != null) { + final Drawable drawable = verifiedDrawable.mutate(); + drawable.setBounds(0, 0, drawableSize, drawableSize); + verifiedSpan = new VerticalImageSpan(drawable); + } + try { + if (verifiedSpan != null) { + sb.append(" "); + sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + Log.e("FeedItemViewHolder", "setUsername: ", e); + } + } + topBinding.title.setText(sb); + } + + public abstract void bindItem(final Media media); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedPhotoViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedPhotoViewHolder.java new file mode 100644 index 0000000..7c007a4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedPhotoViewHolder.java @@ -0,0 +1,79 @@ +package awais.instagrabber.adapters.viewholder.feed; + +import android.net.Uri; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; + +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.databinding.ItemFeedPhotoBinding; +import awais.instagrabber.databinding.LayoutPostViewBottomBinding; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; + +public class FeedPhotoViewHolder extends FeedItemViewHolder { + private static final String TAG = "FeedPhotoViewHolder"; + + private final ItemFeedPhotoBinding binding; + private final FeedAdapterV2.FeedItemCallback feedItemCallback; + + public FeedPhotoViewHolder(@NonNull final ItemFeedPhotoBinding binding, + final FeedAdapterV2.FeedItemCallback feedItemCallback) { + super(binding.getRoot(), feedItemCallback); + this.binding = binding; + this.feedItemCallback = feedItemCallback; + final LayoutPostViewBottomBinding bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); + bottom.viewsCount.setVisibility(View.GONE); + // binding.itemFeedBottom.btnMute.setVisibility(View.GONE); + binding.imageViewer.setAllowTouchInterceptionWhileZoomed(false); + final GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(itemView.getContext().getResources()) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + .build(); + binding.imageViewer.setHierarchy(hierarchy); + } + + @Override + public void bindItem(final Media media) { + if (media == null) return; + binding.getRoot().post(() -> { + setDimensions(media); + final String thumbnailUrl = ResponseBodyUtils.getThumbUrl(media); + String url = ResponseBodyUtils.getImageUrl(media); + if (TextUtils.isEmpty(url)) url = thumbnailUrl; + final ImageRequest requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(url)) + // .setLocalThumbnailPreviewsEnabled(true) + // .setProgressiveRenderingEnabled(true) + .build(); + binding.imageViewer.setController(Fresco.newDraweeControllerBuilder() + .setImageRequest(requestBuilder) + .setOldController(binding.imageViewer.getController()) + .setLowResImageRequest(ImageRequest.fromUri(thumbnailUrl)) + .build()); + binding.imageViewer.setTapListener(new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + if (feedItemCallback != null) { + feedItemCallback.onPostClick(media); + return true; + } + return false; + } + }); + }); + } + + private void setDimensions(final Media feedModel) { + final float aspectRatio = (float) feedModel.getOriginalWidth() / feedModel.getOriginalHeight(); + binding.imageViewer.setAspectRatio(aspectRatio); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedSliderViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedSliderViewHolder.java new file mode 100644 index 0000000..3c52bc4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedSliderViewHolder.java @@ -0,0 +1,173 @@ +package awais.instagrabber.adapters.viewholder.feed; + +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import androidx.annotation.NonNull; +import androidx.viewpager2.widget.ViewPager2; + +import java.util.List; + +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.adapters.SliderCallbackAdapter; +import awais.instagrabber.adapters.SliderItemsAdapter; +import awais.instagrabber.databinding.ItemFeedSliderBinding; +import awais.instagrabber.databinding.LayoutPostViewBottomBinding; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.Utils; + +public class FeedSliderViewHolder extends FeedItemViewHolder { + private static final String TAG = "FeedSliderViewHolder"; + // private static final boolean shouldAutoPlay = settingsHelper.getBoolean(Constants.AUTOPLAY_VIDEOS); + + private final ItemFeedSliderBinding binding; + private final FeedAdapterV2.FeedItemCallback feedItemCallback; + private final LayoutPostViewBottomBinding bottom; + + public FeedSliderViewHolder(@NonNull final ItemFeedSliderBinding binding, + final FeedAdapterV2.FeedItemCallback feedItemCallback) { + super(binding.getRoot(), feedItemCallback); + this.binding = binding; + this.feedItemCallback = feedItemCallback; + bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); + bottom.viewsCount.setVisibility(View.GONE); + // bottom.btnMute.setVisibility(View.GONE); + final ViewGroup.LayoutParams layoutParams = binding.mediaList.getLayoutParams(); + layoutParams.height = Utils.displayMetrics.widthPixels + 1; + binding.mediaList.setLayoutParams(layoutParams); + // final Context context = binding.getRoot().getContext(); + } + + @Override + public void bindItem(final Media feedModel) { + final List sliderItems = feedModel.getCarouselMedia(); + final int sliderItemLen = sliderItems != null ? sliderItems.size() : 0; + if (sliderItemLen <= 0) return; + final String text = "1/" + sliderItemLen; + binding.mediaCounter.setText(text); + binding.mediaList.setOffscreenPageLimit(1); + final SliderItemsAdapter adapter = new SliderItemsAdapter(false, new SliderCallbackAdapter() { + @Override + public void onItemClicked(final int position, final Media media, final View view) { + feedItemCallback.onSliderClick(feedModel, position); + } + }); + binding.mediaList.setAdapter(adapter); + binding.mediaList.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(final int position) { + if (position >= sliderItemLen) return; + final String text = (position + 1) + "/" + sliderItemLen; + binding.mediaCounter.setText(text); + setDimensions(binding.mediaList, sliderItems.get(position)); + bottom.download.setOnClickListener(v -> + feedItemCallback.onDownloadClick(feedModel, position, bottom.download) + ); + } + }); + setDimensions(binding.mediaList, sliderItems.get(0)); + bottom.download.setOnClickListener(v -> + feedItemCallback.onDownloadClick(feedModel, 0, bottom.download) + ); + adapter.submitList(sliderItems); + } + + private void setDimensions(final View view, final Media model) { + final ViewGroup.LayoutParams layoutParams = binding.mediaList.getLayoutParams(); + int requiredWidth = layoutParams.width; + if (requiredWidth <= 0) { + final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + view.getViewTreeObserver().removeOnPreDrawListener(this); + setLayoutParamDimens(binding.mediaList, model); + return true; + } + }; + view.getViewTreeObserver().addOnPreDrawListener(preDrawListener); + return; + } + setLayoutParamDimens(binding.mediaList, model); + } + + private void setLayoutParamDimens(final View view, final Media model) { + final int requiredWidth = view.getMeasuredWidth(); + final ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + final int spanHeight = NumberUtils.getResultingHeight(requiredWidth, model.getOriginalHeight(), model.getOriginalWidth()); + layoutParams.height = spanHeight == 0 ? requiredWidth + 1 : spanHeight; + view.requestLayout(); + } + + // private void autoPlay(final int position) { + // if (!shouldAutoPlay) { + // return; + // } + // final ChildMediaItemsAdapter adapter = (ChildMediaItemsAdapter) binding.mediaList.getAdapter(); + // if (adapter == null) { + // return; + // } + // final ViewerPostModel sliderItem = adapter.getItemAtPosition(position); + // if (sliderItem.getItemType() != MediaItemType.MEDIA_TYPE_VIDEO) { + // return; + // } + // final ViewSwitcher viewSwitcher = (ViewSwitcher) binding.mediaList.getChildAt(position); + // loadPlayer(binding.getRoot().getContext(), + // position, sliderItem.getDisplayUrl(), + // viewSwitcher, + // cacheDataSourceFactory != null ? cacheDataSourceFactory : dataSourceFactory, + // playerChangeListener); + // } + + // public void startPlayingVideo() { + // final int playerPosition = 0; + // autoPlay(playerPosition); + // } + // + // public void stopPlayingVideo() { + // if (pagerPlayer == null) { + // return; + // } + // pagerPlayer.setPlayWhenReady(false); + // } + + // private interface PlayerChangeListener { + // void playerChanged(final int position, final SimpleExoPlayer player); + // } + // + // private static void loadPlayer(final Context context, + // final int position, + // final String displayUrl, + // final ViewSwitcher viewSwitcher, + // final DataSource.Factory factory, + // final PlayerChangeListener playerChangeListener) { + // if (viewSwitcher == null) { + // return; + // } + // SimpleExoPlayer player = (SimpleExoPlayer) viewSwitcher.getTag(); + // if (player != null) { + // player.setPlayWhenReady(true); + // return; + // } + // player = new SimpleExoPlayer.Builder(context).build(); + // final PlayerView playerView = (PlayerView) viewSwitcher.getChildAt(1); + // playerView.setPlayer(player); + // if (viewSwitcher.getDisplayedChild() == 0) { + // viewSwitcher.showNext(); + // } + // playerView.setControllerShowTimeoutMs(1000); + // float vol = settingsHelper.getBoolean(Constants.MUTED_VIDEOS) ? 0f : 1f; + // if (vol == 0f && Utils.sessionVolumeFull) vol = 1f; + // player.setVolume(vol); + // player.setPlayWhenReady(Utils.settingsHelper.getBoolean(Constants.AUTOPLAY_VIDEOS)); + // final MediaItem mediaItem = MediaItem.fromUri(displayUrl); + // final ProgressiveMediaSource mediaSource = new ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem); + // player.setRepeatMode(Player.REPEAT_MODE_ALL); + // player.setMediaSource(mediaSource); + // player.prepare(); + // player.setVolume(vol); + // playerChangeListener.playerChanged(position, player); + // viewSwitcher.setTag(player); + // } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedVideoViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedVideoViewHolder.java new file mode 100644 index 0000000..61f7911 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedVideoViewHolder.java @@ -0,0 +1,150 @@ +package awais.instagrabber.adapters.viewholder.feed; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; + +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.customviews.VideoPlayerCallbackAdapter; +import awais.instagrabber.customviews.VideoPlayerViewHelper; +import awais.instagrabber.databinding.ItemFeedVideoBinding; +import awais.instagrabber.databinding.LayoutPostViewBottomBinding; +import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.MediaCandidate; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class FeedVideoViewHolder extends FeedItemViewHolder { + private static final String TAG = "FeedVideoViewHolder"; + + private final ItemFeedVideoBinding binding; + private final FeedAdapterV2.FeedItemCallback feedItemCallback; + private final Handler handler; + private final DefaultDataSourceFactory dataSourceFactory; + + private final LayoutPostViewBottomBinding bottom; + private CacheDataSourceFactory cacheDataSourceFactory; + private Media media; + + // private final Runnable loadRunnable = new Runnable() { + // @Override + // public void run() { + // // loadPlayer(feedModel); + // } + // }; + + public FeedVideoViewHolder(@NonNull final ItemFeedVideoBinding binding, + final FeedAdapterV2.FeedItemCallback feedItemCallback) { + super(binding.getRoot(), feedItemCallback); + bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); + this.binding = binding; + this.feedItemCallback = feedItemCallback; + bottom.viewsCount.setVisibility(View.VISIBLE); + handler = new Handler(Looper.getMainLooper()); + final Context context = binding.getRoot().getContext(); + dataSourceFactory = new DefaultDataSourceFactory(context, "instagram"); + final SimpleCache simpleCache = Utils.getSimpleCacheInstance(context); + if (simpleCache != null) { + cacheDataSourceFactory = new CacheDataSourceFactory(simpleCache, dataSourceFactory); + } + } + + @Override + public void bindItem(final Media media) { + // Log.d(TAG, "Binding post: " + feedModel.getPostId()); + this.media = media; + final String viewCount = itemView.getResources().getQuantityString(R.plurals.views_count, (int) media.getViewCount(), media.getViewCount()); + bottom.viewsCount.setText(viewCount); + final LayoutVideoPlayerWithThumbnailBinding videoPost = + LayoutVideoPlayerWithThumbnailBinding.inflate(LayoutInflater.from(itemView.getContext()), binding.getRoot(), false); + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) videoPost.getRoot().getLayoutParams(); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(media.getOriginalHeight(), + media.getOriginalWidth(), + (int) (Utils.displayMetrics.heightPixels * 0.8), + Utils.displayMetrics.widthPixels); + layoutParams.width = ConstraintLayout.LayoutParams.MATCH_PARENT; + layoutParams.height = widthHeight.second; + final View postView = videoPost.getRoot(); + binding.postContainer.addView(postView); + final float vol = settingsHelper.getBoolean(PreferenceKeys.MUTED_VIDEOS) ? 0f : 1f; + final VideoPlayerViewHelper.VideoPlayerCallback videoPlayerCallback = new VideoPlayerCallbackAdapter() { + + @Override + public void onThumbnailClick() { + feedItemCallback.onPostClick(media); + } + + @Override + public void onPlayerViewLoaded() { + final ViewGroup.LayoutParams layoutParams = videoPost.playerView.getLayoutParams(); + final int requiredWidth = Utils.displayMetrics.widthPixels; + final int resultingHeight = NumberUtils.getResultingHeight(requiredWidth, media.getOriginalHeight(), media.getOriginalWidth()); + layoutParams.width = requiredWidth; + layoutParams.height = resultingHeight; + videoPost.playerView.requestLayout(); + } + }; + final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); + String videoUrl = null; + final List videoVersions = media.getVideoVersions(); + if (videoVersions != null && !videoVersions.isEmpty()) { + final MediaCandidate videoVersion = videoVersions.get(0); + videoUrl = videoVersion.getUrl(); + } + final VideoPlayerViewHelper videoPlayerViewHelper = new VideoPlayerViewHelper(binding.getRoot().getContext(), + videoPost, + videoUrl, + vol, + aspectRatio, + ResponseBodyUtils.getThumbUrl(media), + false, + // null, + videoPlayerCallback); + videoPost.thumbnail.post(() -> { + if (media.getOriginalHeight() > 0.8 * Utils.displayMetrics.heightPixels) { + final ViewGroup.LayoutParams tLayoutParams = videoPost.thumbnail.getLayoutParams(); + tLayoutParams.height = (int) (0.8 * Utils.displayMetrics.heightPixels); + videoPost.thumbnail.requestLayout(); + } + }); + } + + public Media getCurrentFeedModel() { + return media; + } + + // public void stopPlaying() { + // // Log.d(TAG, "Stopping post: " + feedModel.getPostId() + ", player: " + player + ", player.isPlaying: " + (player != null && player.isPlaying())); + // handler.removeCallbacks(loadRunnable); + // if (player != null) { + // player.release(); + // } + // if (videoPost.root.getDisplayedChild() == 1) { + // videoPost.root.showPrevious(); + // } + // } + // + // public void startPlaying() { + // handler.removeCallbacks(loadRunnable); + // handler.postDelayed(loadRunnable, 800); + // } +} diff --git a/app/src/main/java/awais/instagrabber/animations/CubicBezierInterpolator.java b/app/src/main/java/awais/instagrabber/animations/CubicBezierInterpolator.java new file mode 100644 index 0000000..3c137d7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/animations/CubicBezierInterpolator.java @@ -0,0 +1,74 @@ +package awais.instagrabber.animations; + +import android.graphics.PointF; +import android.view.animation.Interpolator; + +public class CubicBezierInterpolator implements Interpolator { + + public static final CubicBezierInterpolator DEFAULT = new CubicBezierInterpolator(0.25, 0.1, 0.25, 1); + public static final CubicBezierInterpolator EASE_OUT = new CubicBezierInterpolator(0, 0, .58, 1); + public static final CubicBezierInterpolator EASE_OUT_QUINT = new CubicBezierInterpolator(.23, 1, .32, 1); + public static final CubicBezierInterpolator EASE_IN = new CubicBezierInterpolator(.42, 0, 1, 1); + public static final CubicBezierInterpolator EASE_BOTH = new CubicBezierInterpolator(.42, 0, .58, 1); + + protected PointF start; + protected PointF end; + protected PointF a = new PointF(); + protected PointF b = new PointF(); + protected PointF c = new PointF(); + + public CubicBezierInterpolator(PointF start, PointF end) throws IllegalArgumentException { + if (start.x < 0 || start.x > 1) { + throw new IllegalArgumentException("startX value must be in the range [0, 1]"); + } + if (end.x < 0 || end.x > 1) { + throw new IllegalArgumentException("endX value must be in the range [0, 1]"); + } + this.start = start; + this.end = end; + } + + public CubicBezierInterpolator(float startX, float startY, float endX, float endY) { + this(new PointF(startX, startY), new PointF(endX, endY)); + } + + public CubicBezierInterpolator(double startX, double startY, double endX, double endY) { + this((float) startX, (float) startY, (float) endX, (float) endY); + } + + @Override + public float getInterpolation(float time) { + return getBezierCoordinateY(getXForTime(time)); + } + + protected float getBezierCoordinateY(float time) { + c.y = 3 * start.y; + b.y = 3 * (end.y - start.y) - c.y; + a.y = 1 - c.y - b.y; + return time * (c.y + time * (b.y + time * a.y)); + } + + protected float getXForTime(float time) { + float x = time; + float z; + for (int i = 1; i < 14; i++) { + z = getBezierCoordinateX(x) - time; + if (Math.abs(z) < 1e-3) { + break; + } + x -= z / getXDerivate(x); + } + return x; + } + + private float getXDerivate(float t) { + return c.x + t * (2 * b.x + 3 * a.x * t); + } + + private float getBezierCoordinateX(float time) { + c.x = 3 * start.x; + b.x = 3 * (end.x - start.x) - c.x; + a.x = 1 - c.x - b.x; + return time * (c.x + time * (b.x + time * a.x)); + } +} diff --git a/app/src/main/java/awais/instagrabber/animations/FabAnimation.java b/app/src/main/java/awais/instagrabber/animations/FabAnimation.java new file mode 100644 index 0000000..1df54dc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/animations/FabAnimation.java @@ -0,0 +1,61 @@ +package awais.instagrabber.animations; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.view.View; + +// https://medium.com/better-programming/animated-fab-button-with-more-options-2dcf7118fff6 + +public class FabAnimation { + public static boolean rotateFab(final View v, boolean rotate) { + v.animate().setDuration(200) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + } + }) + .rotation(rotate ? 135f : 0f); + return rotate; + } + + public static void showIn(final View v) { + v.setVisibility(View.VISIBLE); + v.setAlpha(0f); + v.setTranslationY(v.getHeight()); + v.animate() + .setDuration(200) + .translationY(0) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + } + }) + .alpha(1f) + .start(); + } + + public static void showOut(final View v) { + v.setVisibility(View.VISIBLE); + v.setAlpha(1f); + v.setTranslationY(0); + v.animate() + .setDuration(200) + .translationY(v.getHeight()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + v.setVisibility(View.GONE); + super.onAnimationEnd(animation); + } + }).alpha(0f) + .start(); + } + + public static void init(final View v) { + v.setVisibility(View.GONE); + v.setTranslationY(v.getHeight()); + v.setAlpha(0f); + } +} diff --git a/app/src/main/java/awais/instagrabber/animations/ResizeAnimation.java b/app/src/main/java/awais/instagrabber/animations/ResizeAnimation.java new file mode 100644 index 0000000..cec093a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/animations/ResizeAnimation.java @@ -0,0 +1,45 @@ +package awais.instagrabber.animations; + +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +public class ResizeAnimation extends Animation { + private static final String TAG = "ResizeAnimation"; + + final View view; + final int startHeight; + final int targetHeight; + final int startWidth; + final int targetWidth; + + public ResizeAnimation(final View view, + final int startHeight, + final int startWidth, + final int targetHeight, + final int targetWidth) { + this.view = view; + this.startHeight = startHeight; + this.targetHeight = targetHeight; + this.startWidth = startWidth; + this.targetWidth = targetWidth; + } + + @Override + protected void applyTransformation(final float interpolatedTime, final Transformation t) { + // Log.d(TAG, "applyTransformation: interpolatedTime: " + interpolatedTime); + view.getLayoutParams().height = (int) (startHeight + (targetHeight - startHeight) * interpolatedTime); + view.getLayoutParams().width = (int) (startWidth + (targetWidth - startWidth) * interpolatedTime); + view.requestLayout(); + } + + @Override + public void initialize(final int width, final int height, final int parentWidth, final int parentHeight) { + super.initialize(width, height, parentWidth, parentHeight); + } + + @Override + public boolean willChangeBounds() { + return true; + } +} diff --git a/app/src/main/java/awais/instagrabber/animations/RevealOutlineAnimation.java b/app/src/main/java/awais/instagrabber/animations/RevealOutlineAnimation.java new file mode 100644 index 0000000..1666171 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/animations/RevealOutlineAnimation.java @@ -0,0 +1,84 @@ +package awais.instagrabber.animations; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.graphics.Outline; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewOutlineProvider; + +/** + * A {@link ViewOutlineProvider} that has helper functions to create reveal animations. + * This class should be extended so that subclasses can define the reveal shape as the + * animation progresses from 0 to 1. + */ +public abstract class RevealOutlineAnimation extends ViewOutlineProvider { + protected Rect mOutline; + protected float mOutlineRadius; + + public RevealOutlineAnimation() { + mOutline = new Rect(); + } + + /** + * Returns whether elevation should be removed for the duration of the reveal animation. + */ + abstract boolean shouldRemoveElevationDuringAnimation(); + + /** + * Sets the progress, from 0 to 1, of the reveal animation. + */ + abstract void setProgress(float progress); + + public ValueAnimator createRevealAnimator(final View revealView, boolean isReversed) { + ValueAnimator va = + isReversed ? ValueAnimator.ofFloat(1f, 0f) : ValueAnimator.ofFloat(0f, 1f); + final float elevation = revealView.getElevation(); + + va.addListener(new AnimatorListenerAdapter() { + private boolean mIsClippedToOutline; + private ViewOutlineProvider mOldOutlineProvider; + + public void onAnimationStart(Animator animation) { + mIsClippedToOutline = revealView.getClipToOutline(); + mOldOutlineProvider = revealView.getOutlineProvider(); + + revealView.setOutlineProvider(RevealOutlineAnimation.this); + revealView.setClipToOutline(true); + if (shouldRemoveElevationDuringAnimation()) { + revealView.setTranslationZ(-elevation); + } + } + + public void onAnimationEnd(Animator animation) { + revealView.setOutlineProvider(mOldOutlineProvider); + revealView.setClipToOutline(mIsClippedToOutline); + if (shouldRemoveElevationDuringAnimation()) { + revealView.setTranslationZ(0); + } + } + + }); + + va.addUpdateListener(v -> { + float progress = (Float) v.getAnimatedValue(); + setProgress(progress); + revealView.invalidateOutline(); + }); + return va; + } + + @Override + public void getOutline(View v, Outline outline) { + outline.setRoundRect(mOutline, mOutlineRadius); + } + + public float getRadius() { + return mOutlineRadius; + } + + public void getOutline(Rect out) { + out.set(mOutline); + } +} diff --git a/app/src/main/java/awais/instagrabber/animations/RoundedRectRevealOutlineProvider.java b/app/src/main/java/awais/instagrabber/animations/RoundedRectRevealOutlineProvider.java new file mode 100644 index 0000000..c5cf043 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/animations/RoundedRectRevealOutlineProvider.java @@ -0,0 +1,56 @@ +/* + * 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 awais.instagrabber.animations; + +import android.graphics.Rect; + +/** + * A {@link RevealOutlineAnimation} that provides an outline that interpolates between two radii + * and two {@link Rect}s. + *

+ * An example usage of this provider is an outline that starts out as a circle and ends + * as a rounded rectangle. + */ +public class RoundedRectRevealOutlineProvider extends RevealOutlineAnimation { + private final float mStartRadius; + private final float mEndRadius; + + private final Rect mStartRect; + private final Rect mEndRect; + + public RoundedRectRevealOutlineProvider(float startRadius, float endRadius, Rect startRect, Rect endRect) { + mStartRadius = startRadius; + mEndRadius = endRadius; + mStartRect = startRect; + mEndRect = endRect; + } + + @Override + public boolean shouldRemoveElevationDuringAnimation() { + return false; + } + + @Override + public void setProgress(float progress) { + mOutlineRadius = (1 - progress) * mStartRadius + progress * mEndRadius; + + mOutline.left = (int) ((1 - progress) * mStartRect.left + progress * mEndRect.left); + mOutline.top = (int) ((1 - progress) * mStartRect.top + progress * mEndRect.top); + mOutline.right = (int) ((1 - progress) * mStartRect.right + progress * mEndRect.right); + mOutline.bottom = (int) ((1 - progress) * mStartRect.bottom + progress * mEndRect.bottom); + } +} diff --git a/app/src/main/java/awais/instagrabber/animations/ScaleAnimation.java b/app/src/main/java/awais/instagrabber/animations/ScaleAnimation.java new file mode 100644 index 0000000..c4e8193 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/animations/ScaleAnimation.java @@ -0,0 +1,45 @@ +package awais.instagrabber.animations; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +public class ScaleAnimation { + + private final View view; + + public ScaleAnimation(View view) { + this.view = view; + } + + + public void start() { + AnimatorSet set = new AnimatorSet(); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 2.0f); + + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 2.0f); + set.setDuration(150); + set.setInterpolator(new AccelerateDecelerateInterpolator()); + set.playTogether(scaleY, scaleX); + set.start(); + } + + public void stop() { + AnimatorSet set = new AnimatorSet(); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1.0f); + // scaleY.setDuration(250); + // scaleY.setInterpolator(new DecelerateInterpolator()); + + + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1.0f); + // scaleX.setDuration(250); + // scaleX.setInterpolator(new DecelerateInterpolator()); + + + set.setDuration(150); + set.setInterpolator(new AccelerateDecelerateInterpolator()); + set.playTogether(scaleY, scaleX); + set.start(); + } +} diff --git a/app/src/main/java/awais/instagrabber/asyncs/DiscoverPostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/DiscoverPostFetchService.java new file mode 100644 index 0000000..0cf9c22 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/asyncs/DiscoverPostFetchService.java @@ -0,0 +1,71 @@ +package awais.instagrabber.asyncs; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; +import awais.instagrabber.repositories.responses.WrappedMedia; +import awais.instagrabber.webservices.DiscoverService; +import awais.instagrabber.webservices.ServiceCallback; + +public class DiscoverPostFetchService implements PostFetcher.PostFetchService { + private static final String TAG = "DiscoverPostFetchService"; + private final DiscoverService discoverService; + private final DiscoverService.TopicalExploreRequest topicalExploreRequest; + private boolean moreAvailable = false; + + public DiscoverPostFetchService(final DiscoverService.TopicalExploreRequest topicalExploreRequest) { + this.topicalExploreRequest = topicalExploreRequest; + discoverService = DiscoverService.getInstance(); + } + + @Override + public void fetch(final FetchListener> fetchListener) { + discoverService.topicalExplore(topicalExploreRequest, new ServiceCallback() { + @Override + public void onSuccess(final TopicalExploreFeedResponse result) { + if (result == null) { + onFailure(new RuntimeException("result is null")); + return; + } + moreAvailable = result.getMoreAvailable(); + topicalExploreRequest.setMaxId(result.getNextMaxId()); + final List items = result.getItems(); + final List posts; + if (items == null) { + posts = Collections.emptyList(); + } else { + posts = items.stream() + .map(WrappedMedia::getMedia) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + if (fetchListener != null) { + fetchListener.onResult(posts); + } + } + + @Override + public void onFailure(final Throwable t) { + if (fetchListener != null) { + fetchListener.onFailure(t); + } + } + }); + } + + @Override + public void reset() { + topicalExploreRequest.setMaxId(null); + } + + @Override + public boolean hasNextPage() { + return moreAvailable; + } +} diff --git a/app/src/main/java/awais/instagrabber/asyncs/FeedPostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/FeedPostFetchService.java new file mode 100644 index 0000000..9664333 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/asyncs/FeedPostFetchService.java @@ -0,0 +1,74 @@ +package awais.instagrabber.asyncs; + +import java.util.ArrayList; +import java.util.List; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.webservices.FeedService; +import awais.instagrabber.webservices.ServiceCallback; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class FeedPostFetchService implements PostFetcher.PostFetchService { + private static final String TAG = "FeedPostFetchService"; + private final FeedService feedService; + private String nextCursor; + private boolean hasNextPage; + + public FeedPostFetchService() { + feedService = FeedService.getInstance(); + } + + @Override + public void fetch(final FetchListener> fetchListener) { + final List feedModels = new ArrayList<>(); + final String cookie = settingsHelper.getString(Constants.COOKIE); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); + feedModels.clear(); + feedService.fetch(csrfToken, deviceUuid, nextCursor, new ServiceCallback() { + @Override + public void onSuccess(final PostsFetchResponse result) { + if (result == null && feedModels.size() > 0) { + fetchListener.onResult(feedModels); + return; + } else if (result == null) return; + nextCursor = result.getNextCursor(); + hasNextPage = result.getHasNextPage(); + + final List mediaResults = result.getFeedModels(); + feedModels.addAll(mediaResults); + + if (fetchListener != null) { + // if (feedModels.size() < 15 && hasNextPage) { + // feedService.fetch(csrfToken, nextCursor, this); + // } else { + fetchListener.onResult(feedModels); + // } + } + } + + @Override + public void onFailure(final Throwable t) { + if (fetchListener != null) { + fetchListener.onFailure(t); + } + } + }); + } + + @Override + public void reset() { + nextCursor = null; + } + + @Override + public boolean hasNextPage() { + return hasNextPage; + } +} diff --git a/app/src/main/java/awais/instagrabber/asyncs/HashtagPostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/HashtagPostFetchService.java new file mode 100644 index 0000000..7b26d79 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/asyncs/HashtagPostFetchService.java @@ -0,0 +1,75 @@ +package awais.instagrabber.asyncs; + +import java.util.List; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.ServiceCallback; +import awais.instagrabber.webservices.TagsService; +import kotlinx.coroutines.Dispatchers; + +public class HashtagPostFetchService implements PostFetcher.PostFetchService { + private final TagsService tagsService; + private final GraphQLRepository graphQLRepository; + private final Hashtag hashtagModel; + private String nextMaxId; + private boolean moreAvailable; + private final boolean isLoggedIn; + + public HashtagPostFetchService(final Hashtag hashtagModel, final boolean isLoggedIn) { + this.hashtagModel = hashtagModel; + this.isLoggedIn = isLoggedIn; + tagsService = isLoggedIn ? TagsService.getInstance() : null; + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + } + + @Override + public void fetch(final FetchListener> fetchListener) { + final ServiceCallback cb = new ServiceCallback() { + @Override + public void onSuccess(final PostsFetchResponse result) { + if (result == null) return; + nextMaxId = result.getNextCursor(); + moreAvailable = result.getHasNextPage(); + if (fetchListener != null) { + fetchListener.onResult(result.getFeedModels()); + } + } + + @Override + public void onFailure(final Throwable t) { + // Log.e(TAG, "onFailure: ", t); + if (fetchListener != null) { + fetchListener.onFailure(t); + } + } + }; + if (isLoggedIn) tagsService.fetchPosts(hashtagModel.getName().toLowerCase(), nextMaxId, cb); + else graphQLRepository.fetchHashtagPosts( + hashtagModel.getName().toLowerCase(), + nextMaxId, + CoroutineUtilsKt.getContinuation((postsFetchResponse, throwable) -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + cb.onSuccess(postsFetchResponse); + }, Dispatchers.getIO()) + ); + } + + @Override + public void reset() { + nextMaxId = null; + } + + @Override + public boolean hasNextPage() { + return moreAvailable; + } +} diff --git a/app/src/main/java/awais/instagrabber/asyncs/LocationPostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/LocationPostFetchService.java new file mode 100644 index 0000000..11f5dce --- /dev/null +++ b/app/src/main/java/awais/instagrabber/asyncs/LocationPostFetchService.java @@ -0,0 +1,75 @@ +package awais.instagrabber.asyncs; + +import java.util.List; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.LocationService; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +public class LocationPostFetchService implements PostFetcher.PostFetchService { + private final LocationService locationService; + private final GraphQLRepository graphQLRepository; + private final Location locationModel; + private String nextMaxId; + private boolean moreAvailable; + private final boolean isLoggedIn; + + public LocationPostFetchService(final Location locationModel, final boolean isLoggedIn) { + this.locationModel = locationModel; + this.isLoggedIn = isLoggedIn; + locationService = isLoggedIn ? LocationService.getInstance() : null; + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + } + + @Override + public void fetch(final FetchListener> fetchListener) { + final ServiceCallback cb = new ServiceCallback() { + @Override + public void onSuccess(final PostsFetchResponse result) { + if (result == null) return; + nextMaxId = result.getNextCursor(); + moreAvailable = result.getHasNextPage(); + if (fetchListener != null) { + fetchListener.onResult(result.getFeedModels()); + } + } + + @Override + public void onFailure(final Throwable t) { + // Log.e(TAG, "onFailure: ", t); + if (fetchListener != null) { + fetchListener.onFailure(t); + } + } + }; + if (isLoggedIn) locationService.fetchPosts(locationModel.getPk(), nextMaxId, cb); + else graphQLRepository.fetchLocationPosts( + locationModel.getPk(), + nextMaxId, + CoroutineUtilsKt.getContinuation((postsFetchResponse, throwable) -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + cb.onSuccess(postsFetchResponse); + }, Dispatchers.getIO()) + ); + } + + @Override + public void reset() { + nextMaxId = null; + } + + @Override + public boolean hasNextPage() { + return moreAvailable; + } +} diff --git a/app/src/main/java/awais/instagrabber/asyncs/ProfilePostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/ProfilePostFetchService.java new file mode 100644 index 0000000..9bff389 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/asyncs/ProfilePostFetchService.java @@ -0,0 +1,78 @@ +package awais.instagrabber.asyncs; + +import java.util.List; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.ProfileService; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +public class ProfilePostFetchService implements PostFetcher.PostFetchService { + private static final String TAG = "ProfilePostFetchService"; + private final ProfileService profileService; + private final GraphQLRepository graphQLRepository; + private final User profileModel; + private final boolean isLoggedIn; + private String nextMaxId; + private boolean moreAvailable; + + public ProfilePostFetchService(final User profileModel, final boolean isLoggedIn) { + this.profileModel = profileModel; + this.isLoggedIn = isLoggedIn; + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + profileService = isLoggedIn ? ProfileService.getInstance() : null; + } + + @Override + public void fetch(final FetchListener> fetchListener) { + final ServiceCallback cb = new ServiceCallback() { + @Override + public void onSuccess(final PostsFetchResponse result) { + if (result == null) return; + nextMaxId = result.getNextCursor(); + moreAvailable = result.getHasNextPage(); + if (fetchListener != null) { + fetchListener.onResult(result.getFeedModels()); + } + } + + @Override + public void onFailure(final Throwable t) { + // Log.e(TAG, "onFailure: ", t); + if (fetchListener != null) { + fetchListener.onFailure(t); + } + } + }; + if (isLoggedIn) profileService.fetchPosts(profileModel.getPk(), nextMaxId, cb); + else graphQLRepository.fetchProfilePosts( + profileModel.getPk(), + 30, + nextMaxId, + profileModel, + CoroutineUtilsKt.getContinuation((postsFetchResponse, throwable) -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + cb.onSuccess(postsFetchResponse); + }, Dispatchers.getIO()) + ); + } + + @Override + public void reset() { + nextMaxId = null; + } + + @Override + public boolean hasNextPage() { + return moreAvailable; + } +} diff --git a/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java b/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java new file mode 100644 index 0000000..e4650a7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java @@ -0,0 +1,94 @@ +package awais.instagrabber.asyncs; + +import java.util.List; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.models.enums.PostItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.ProfileService; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +public class SavedPostFetchService implements PostFetcher.PostFetchService { + private final ProfileService profileService; + private final GraphQLRepository graphQLRepository; + private final long profileId; + private final PostItemType type; + private final boolean isLoggedIn; + + private String nextMaxId; + private final String collectionId; + private boolean moreAvailable; + + public SavedPostFetchService(final long profileId, final PostItemType type, final boolean isLoggedIn, final String collectionId) { + this.profileId = profileId; + this.type = type; + this.isLoggedIn = isLoggedIn; + this.collectionId = collectionId; + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + profileService = isLoggedIn ? ProfileService.getInstance() : null; + } + + @Override + public void fetch(final FetchListener> fetchListener) { + final ServiceCallback callback = new ServiceCallback() { + @Override + public void onSuccess(final PostsFetchResponse result) { + if (result == null) return; + nextMaxId = result.getNextCursor(); + moreAvailable = result.getHasNextPage(); + if (fetchListener != null) { + fetchListener.onResult(result.getFeedModels()); + } + } + + @Override + public void onFailure(final Throwable t) { + // Log.e(TAG, "onFailure: ", t); + if (fetchListener != null) { + fetchListener.onFailure(t); + } + } + }; + switch (type) { + case LIKED: + profileService.fetchLiked(nextMaxId, callback); + break; + case TAGGED: + if (isLoggedIn) profileService.fetchTagged(profileId, nextMaxId, callback); + else graphQLRepository.fetchTaggedPosts( + profileId, + 30, + nextMaxId, + CoroutineUtilsKt.getContinuation((postsFetchResponse, throwable) -> { + if (throwable != null) { + callback.onFailure(throwable); + return; + } + callback.onSuccess(postsFetchResponse); + }, Dispatchers.getIO()) + ); + break; + case COLLECTION: + case SAVED: + profileService.fetchSaved(nextMaxId, collectionId, callback); + break; + default: + callback.onFailure(null); + } + } + + @Override + public void reset() { + nextMaxId = null; + } + + @Override + public boolean hasNextPage() { + return moreAvailable; + } +} diff --git a/app/src/main/java/awais/instagrabber/backup/BarinstaBackupAgent.kt b/app/src/main/java/awais/instagrabber/backup/BarinstaBackupAgent.kt new file mode 100644 index 0000000..54b4958 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/backup/BarinstaBackupAgent.kt @@ -0,0 +1,22 @@ +package awais.instagrabber.backup + +import android.app.backup.BackupAgent +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.FullBackupDataOutput +import android.os.ParcelFileDescriptor +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.utils.Utils.settingsHelper + +class BarinstaBackupAgent : BackupAgent() { + override fun onFullBackup(data: FullBackupDataOutput?) { + super.onFullBackup(if (settingsHelper.getBoolean(PreferenceKeys.PREF_AUTO_BACKUP_ENABLED)) data else null) + } + + // no key-value backups + override fun onBackup(oldState: ParcelFileDescriptor?, + data: BackupDataOutput?, newState: ParcelFileDescriptor?) {} + + override fun onRestore(data: BackupDataInput, appVersionCode: Int, + newState: ParcelFileDescriptor) {} +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/broadcasts/DMRefreshBroadcastReceiver.java b/app/src/main/java/awais/instagrabber/broadcasts/DMRefreshBroadcastReceiver.java new file mode 100644 index 0000000..3d2151a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/broadcasts/DMRefreshBroadcastReceiver.java @@ -0,0 +1,27 @@ +package awais.instagrabber.broadcasts; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public class DMRefreshBroadcastReceiver extends BroadcastReceiver { + public static final String ACTION_REFRESH_DM = "action_refresh_dm"; + private final OnDMRefreshCallback callback; + + public DMRefreshBroadcastReceiver(final OnDMRefreshCallback callback) { + this.callback = callback; + } + + @Override + public void onReceive(final Context context, final Intent intent) { + if (callback == null) return; + final String action = intent.getAction(); + if (action == null) return; + if (!action.equals(ACTION_REFRESH_DM)) return; + callback.onReceive(); + } + + public interface OnDMRefreshCallback { + void onReceive(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/BarinstaFragmentNavigator.kt b/app/src/main/java/awais/instagrabber/customviews/BarinstaFragmentNavigator.kt new file mode 100644 index 0000000..1220c0d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/BarinstaFragmentNavigator.kt @@ -0,0 +1,90 @@ +package awais.instagrabber.customviews + +import android.content.Context +import androidx.fragment.app.FragmentManager +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.fragment.FragmentNavigator +import androidx.navigation.navOptions +import awais.instagrabber.R +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.utils.Utils + +private val defaultNavOptions = navOptions { + anim { + enter = R.anim.slide_in_right + exit = R.anim.slide_out_left + popEnter = android.R.anim.slide_in_left + popExit = android.R.anim.slide_out_right + } +} + +private val emptyNavOptions = navOptions {} + +/** + * Needs to replace FragmentNavigator and replacing is done with name in annotation. + * Navigation method will use defaults for fragments transitions animations. + */ +@Navigator.Name("fragment") +class BarinstaFragmentNavigator( + context: Context, + fragmentManager: FragmentManager, + containerId: Int +) : FragmentNavigator(context, fragmentManager, containerId) { + + override fun navigate( + entries: List, + navOptions: NavOptions?, + navigatorExtras: Navigator.Extras? + ) { + val disableTransitions = Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_DISABLE_SCREEN_TRANSITIONS) + if (disableTransitions) { + super.navigate(entries, navOptions, navigatorExtras) + return + } + // this will try to fill in empty animations with defaults when no shared element transitions + // https://developer.android.com/guide/navigation/navigation-animate-transitions#shared-element + val hasSharedElements = navigatorExtras != null && navigatorExtras is Extras + val navOptions1 = if (hasSharedElements) navOptions else navOptions.fillEmptyAnimationsWithDefaults() + super.navigate(entries, navOptions1, navigatorExtras) + } + + private fun NavOptions?.fillEmptyAnimationsWithDefaults(): NavOptions = + this?.copyNavOptionsWithDefaultAnimations() ?: defaultNavOptions + + private fun NavOptions.copyNavOptionsWithDefaultAnimations(): NavOptions = let { originalNavOptions -> + navOptions { + launchSingleTop = originalNavOptions.shouldLaunchSingleTop() + popUpTo(originalNavOptions.popUpToId) { + inclusive = originalNavOptions.isPopUpToInclusive() + saveState = originalNavOptions.shouldPopUpToSaveState() + } + originalNavOptions.popUpToRoute?.let { + popUpTo(it) { + inclusive = originalNavOptions.isPopUpToInclusive() + saveState = originalNavOptions.shouldPopUpToSaveState() + } + } + restoreState = originalNavOptions.shouldRestoreState() + anim { + enter = + if (originalNavOptions.enterAnim == emptyNavOptions.enterAnim) defaultNavOptions.enterAnim + else originalNavOptions.enterAnim + exit = + if (originalNavOptions.exitAnim == emptyNavOptions.exitAnim) defaultNavOptions.exitAnim + else originalNavOptions.exitAnim + popEnter = + if (originalNavOptions.popEnterAnim == emptyNavOptions.popEnterAnim) defaultNavOptions.popEnterAnim + else originalNavOptions.popEnterAnim + popExit = + if (originalNavOptions.popExitAnim == emptyNavOptions.popExitAnim) defaultNavOptions.popExitAnim + else originalNavOptions.popExitAnim + } + } + } + + private companion object { + private const val TAG = "FragmentNavigator" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/BarinstaNavHostFragment.kt b/app/src/main/java/awais/instagrabber/customviews/BarinstaNavHostFragment.kt new file mode 100644 index 0000000..24979a9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/BarinstaNavHostFragment.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.customviews + +import androidx.navigation.NavHostController +import androidx.navigation.fragment.NavHostFragment + +class BarinstaNavHostFragment : NavHostFragment() { + override fun onCreateNavHostController(navHostController: NavHostController) { + super.onCreateNavHostController(navHostController) + navHostController.navigatorProvider.addNavigator( + // this replaces FragmentNavigator + BarinstaFragmentNavigator(requireContext(), childFragmentManager, id) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/ChatMessageLayout.java b/app/src/main/java/awais/instagrabber/customviews/ChatMessageLayout.java new file mode 100644 index 0000000..7b50a06 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/ChatMessageLayout.java @@ -0,0 +1,174 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import awais.instagrabber.R; + +public class ChatMessageLayout extends FrameLayout { + + private FrameLayout viewPartMain; + private View viewPartInfo; + private TypedArray a; + + private int viewPartInfoWidth; + private int viewPartInfoHeight; + + // private boolean withGroupHeader = false; + + public ChatMessageLayout(@NonNull final Context context) { + super(context); + } + + public ChatMessageLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + a = context.obtainStyledAttributes(attrs, R.styleable.ChatMessageLayout, 0, 0); + } + + public ChatMessageLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + a = context.obtainStyledAttributes(attrs, R.styleable.ChatMessageLayout, defStyleAttr, 0); + } + + public ChatMessageLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + a = context.obtainStyledAttributes(attrs, R.styleable.ChatMessageLayout, defStyleAttr, defStyleRes); + } + + // public void setWithGroupHeader(boolean withGroupHeader) { + // this.withGroupHeader = withGroupHeader; + // } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + try { + viewPartMain = findViewById(a.getResourceId(R.styleable.ChatMessageLayout_viewPartMain, -1)); + viewPartInfo = findViewById(a.getResourceId(R.styleable.ChatMessageLayout_viewPartInfo, -1)); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSize; + // heightSize = MeasureSpec.getSize(heightMeasureSpec); + + if (viewPartMain == null || viewPartInfo == null || widthSize <= 0) { + return; + } + + final View firstChild = viewPartMain.getChildAt(0); + if (firstChild == null) return; + + final int firstChildId = firstChild.getId(); + + int availableWidth = widthSize - getPaddingLeft() - getPaddingRight(); + // int availableHeight = heightSize - getPaddingTop() - getPaddingBottom(); + + final LayoutParams viewPartMainLayoutParams = (LayoutParams) viewPartMain.getLayoutParams(); + final int viewPartMainWidth = viewPartMain.getMeasuredWidth() + viewPartMainLayoutParams.leftMargin + viewPartMainLayoutParams.rightMargin; + final int viewPartMainHeight = viewPartMain.getMeasuredHeight() + viewPartMainLayoutParams.topMargin + viewPartMainLayoutParams.bottomMargin; + + final LayoutParams viewPartInfoLayoutParams = (LayoutParams) viewPartInfo.getLayoutParams(); + viewPartInfoWidth = viewPartInfo.getMeasuredWidth() + viewPartInfoLayoutParams.leftMargin + viewPartInfoLayoutParams.rightMargin; + viewPartInfoHeight = viewPartInfo.getMeasuredHeight() + viewPartInfoLayoutParams.topMargin + viewPartInfoLayoutParams.bottomMargin; + + widthSize = getPaddingLeft() + getPaddingRight(); + heightSize = getPaddingTop() + getPaddingBottom(); + if (firstChildId == R.id.media_container) { + widthSize += viewPartMainWidth; + heightSize += viewPartMainHeight; + } else if (firstChildId == R.id.raven_media_container || firstChildId == R.id.profile_container || firstChildId == R.id.voice_media + || firstChildId == R.id.story_container || firstChildId == R.id.media_share_container || firstChildId == R.id.link_container + || firstChildId == R.id.ivAnimatedMessage || firstChildId == R.id.reel_share_container) { + widthSize += viewPartMainWidth; + heightSize += viewPartMainHeight + viewPartInfoHeight; + } else { + int viewPartMainLineCount = 1; + float viewPartMainLastLineWidth = 0; + final TextView textMessage; + if (firstChild instanceof TextView) { + textMessage = (TextView) firstChild; + } + else textMessage = null; + if (textMessage != null) { + viewPartMainLineCount = textMessage.getLineCount(); + viewPartMainLastLineWidth = viewPartMainLineCount > 0 + ? textMessage.getLayout().getLineWidth(viewPartMainLineCount - 1) + : 0; + // also include start left padding + viewPartMainLastLineWidth += textMessage.getPaddingLeft(); + } + + final float lastLineWithInfoWidth = viewPartMainLastLineWidth + viewPartInfoWidth; + if (viewPartMainLineCount > 1 && lastLineWithInfoWidth <= viewPartMain.getMeasuredWidth()) { + widthSize += viewPartMainWidth; + heightSize += viewPartMainHeight; + } else if (viewPartMainLineCount > 1 && (lastLineWithInfoWidth > availableWidth)) { + widthSize += viewPartMainWidth; + heightSize += viewPartMainHeight + viewPartInfoHeight; + } else if (viewPartMainLineCount == 1 && (viewPartMainWidth + viewPartInfoWidth > availableWidth)) { + widthSize += viewPartMain.getMeasuredWidth(); + heightSize += viewPartMainHeight + viewPartInfoHeight; + } else { + heightSize += viewPartMainHeight; + widthSize += viewPartMainWidth + viewPartInfoWidth; + } + + // if (isInEditMode()) { + // TextView wDebugView = (TextView) ((ViewGroup) this.getParent()).findViewWithTag("debug"); + // wDebugView.setText(lastLineWithInfoWidth + // + "\n" + availableWidth + // + "\n" + viewPartMain.getMeasuredWidth() + // + "\n" + (lastLineWithInfoWidth <= viewPartMain.getMeasuredWidth()) + // + "\n" + (lastLineWithInfoWidth > availableWidth) + // + "\n" + (viewPartMainWidth + viewPartInfoWidth > availableWidth)); + // } + } + setMeasuredDimension(widthSize, heightSize); + super.onMeasure(MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)); + + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + if (viewPartMain == null || viewPartInfo == null) { + return; + } + // if (withGroupHeader) { + // viewPartMain.layout( + // getPaddingLeft(), + // getPaddingTop() - Utils.convertDpToPx(4), + // viewPartMain.getWidth() + getPaddingLeft(), + // viewPartMain.getHeight() + getPaddingTop()); + // + // } else { + viewPartMain.layout( + getPaddingLeft(), + getPaddingTop(), + viewPartMain.getWidth() + getPaddingLeft(), + viewPartMain.getHeight() + getPaddingTop()); + + // } + viewPartInfo.layout( + right - left - viewPartInfoWidth - getPaddingRight(), + bottom - top - getPaddingBottom() - viewPartInfoHeight, + right - left - getPaddingRight(), + bottom - top - getPaddingBottom()); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/CircularImageView.java b/app/src/main/java/awais/instagrabber/customviews/CircularImageView.java new file mode 100755 index 0000000..2f68786 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/CircularImageView.java @@ -0,0 +1,63 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; + +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.GenericDraweeHierarchyInflater; +import com.facebook.drawee.generic.RoundingParams; +import com.facebook.drawee.view.SimpleDraweeView; + +import awais.instagrabber.R; + +public class CircularImageView extends SimpleDraweeView { + public CircularImageView(Context context, GenericDraweeHierarchy hierarchy) { + super(context); + setHierarchy(hierarchy); + } + + public CircularImageView(final Context context) { + super(context); + inflateHierarchy(context, null); + } + + public CircularImageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + inflateHierarchy(context, attrs); + } + + public CircularImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflateHierarchy(context, attrs); + } + + protected void inflateHierarchy(Context context, @Nullable AttributeSet attrs) { + Resources resources = context.getResources(); + final RoundingParams roundingParams = RoundingParams.asCircle(); + GenericDraweeHierarchyBuilder builder = new GenericDraweeHierarchyBuilder(resources) + .setRoundingParams(roundingParams) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER); + GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs); + setAspectRatio(builder.getDesiredAspectRatio()); + setHierarchy(builder.build()); + setBackgroundResource(R.drawable.shape_oval_light); + } + + /* types: 0 clear, 1 green (feed bestie / has story), 2 red (live) */ + public void setStoriesBorder(final int type) { + // private final int borderSize = 8; + final int color = type == 2 ? Color.RED : Color.GREEN; + RoundingParams roundingParams = getHierarchy().getRoundingParams(); + if (roundingParams == null) { + roundingParams = RoundingParams.asCircle().setRoundingMethod(RoundingParams.RoundingMethod.BITMAP_ONLY); + } + roundingParams.setBorder(color, type == 0 ? 0f : 5.0f); + getHierarchy().setRoundingParams(roundingParams); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/CommentMentionClickSpan.java b/app/src/main/java/awais/instagrabber/customviews/CommentMentionClickSpan.java new file mode 100755 index 0000000..a68509c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/CommentMentionClickSpan.java @@ -0,0 +1,17 @@ +package awais.instagrabber.customviews; + +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.view.View; + +import androidx.annotation.NonNull; + +public final class CommentMentionClickSpan extends ClickableSpan { + @Override + public void onClick(@NonNull final View widget) { } + + @Override + public void updateDrawState(@NonNull final TextPaint ds) { + ds.setColor(ds.linkColor); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/DirectItemContextMenu.java b/app/src/main/java/awais/instagrabber/customviews/DirectItemContextMenu.java new file mode 100644 index 0000000..b13a3a4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/DirectItemContextMenu.java @@ -0,0 +1,477 @@ +package awais.instagrabber.customviews; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Point; +import android.graphics.Rect; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.ImageView; +import android.widget.PopupWindow; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.util.Pair; + +import java.util.List; +import java.util.function.Function; + +import awais.instagrabber.R; +import awais.instagrabber.animations.RoundedRectRevealOutlineProvider; +import awais.instagrabber.customviews.emoji.Emoji; +import awais.instagrabber.customviews.emoji.ReactionsManager; +import awais.instagrabber.databinding.LayoutDirectItemOptionsBinding; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; + +import static android.view.View.MeasureSpec.makeMeasureSpec; + +public class DirectItemContextMenu extends PopupWindow { + private static final String TAG = DirectItemContextMenu.class.getSimpleName(); + private static final int DO_NOT_UPDATE_FLAG = -1; + private static final int DURATION = 300; + + private final Context context; + private final boolean showReactions; + private final ReactionsManager reactionsManager; + private final int emojiSize; + private final int emojiMargin; + private final int emojiMarginHalf; + private final Rect startRect = new Rect(); + private final Rect endRect = new Rect(); + private final TimeInterpolator revealInterpolator = new AccelerateDecelerateInterpolator(); + private final AnimatorListenerAdapter exitAnimationListener; + private final TypedValue selectableItemBackgroundBorderless; + private final TypedValue selectableItemBackground; + private final int dividerHeight; + private final int optionHeight; + private final int optionPadding; + private final int addAdjust; + private final boolean hasOptions; + private final List options; + private final int widthWithoutReactions; + + private AnimatorSet openCloseAnimator; + private Point location; + private Point point; + private OnReactionClickListener onReactionClickListener; + private OnOptionSelectListener onOptionSelectListener; + private OnAddReactionClickListener onAddReactionListener; + + public DirectItemContextMenu(@NonNull final Context context, final boolean showReactions, final List options) { + super(context); + this.context = context; + this.showReactions = showReactions; + this.options = options; + if (!showReactions && (options == null || options.isEmpty())) { + throw new IllegalArgumentException("showReactions is set false and options are empty"); + } + reactionsManager = ReactionsManager.getInstance(context); + final Resources resources = context.getResources(); + emojiSize = resources.getDimensionPixelSize(R.dimen.reaction_picker_emoji_size); + emojiMargin = resources.getDimensionPixelSize(R.dimen.reaction_picker_emoji_margin); + emojiMarginHalf = emojiMargin / 2; + addAdjust = resources.getDimensionPixelSize(R.dimen.reaction_picker_add_padding_adjustment); + dividerHeight = resources.getDimensionPixelSize(R.dimen.horizontal_divider_height); + optionHeight = resources.getDimensionPixelSize(R.dimen.reaction_picker_option_height); + optionPadding = resources.getDimensionPixelSize(R.dimen.dm_message_card_radius); + widthWithoutReactions = resources.getDimensionPixelSize(R.dimen.dm_item_context_min_width); + exitAnimationListener = new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + openCloseAnimator = null; + point = null; + getContentView().post(DirectItemContextMenu.super::dismiss); + } + }; + selectableItemBackgroundBorderless = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, selectableItemBackgroundBorderless, true); + selectableItemBackground = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, selectableItemBackground, true); + hasOptions = options != null && !options.isEmpty(); + } + + public void show(@NonNull View rootView, @NonNull final Point location) { + final View content = createContentView(); + content.measure(makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + setup(content); + // rootView.getParent().requestDisallowInterceptTouchEvent(true); + // final Point correctedLocation = new Point(location.x, location.y - emojiSize * 2); + this.location = location; + showAtLocation(rootView, Gravity.TOP | Gravity.START, location.x, location.y); + // fixPopupLocation(popupWindow, correctedLocation); + animateOpen(); + } + + private void setup(final View content) { + setContentView(content); + setWindowLayoutMode(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + setFocusable(true); + setOutsideTouchable(true); + setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); + setBackgroundDrawable(null); + } + + public void setOnOptionSelectListener(final OnOptionSelectListener onOptionSelectListener) { + this.onOptionSelectListener = onOptionSelectListener; + } + + public void setOnReactionClickListener(final OnReactionClickListener onReactionClickListener) { + this.onReactionClickListener = onReactionClickListener; + } + + public void setOnAddReactionListener(final OnAddReactionClickListener onAddReactionListener) { + this.onAddReactionListener = onAddReactionListener; + } + + private void animateOpen() { + final View contentView = getContentView(); + contentView.setVisibility(View.INVISIBLE); + contentView.post(() -> { + final AnimatorSet openAnim = new AnimatorSet(); + // Rectangular reveal. + final ValueAnimator revealAnim = createOpenCloseOutlineProvider().createRevealAnimator(contentView, false); + revealAnim.setDuration(DURATION); + revealAnim.setInterpolator(revealInterpolator); + + ValueAnimator fadeIn = ValueAnimator.ofFloat(0, 1); + fadeIn.setDuration(DURATION); + fadeIn.setInterpolator(revealInterpolator); + fadeIn.addUpdateListener(anim -> { + float alpha = (float) anim.getAnimatedValue(); + contentView.setAlpha(revealAnim.isStarted() ? alpha : 0); + }); + openAnim.play(fadeIn); + openAnim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + contentView.setAlpha(1f); + openCloseAnimator = null; + } + }); + + openCloseAnimator = openAnim; + openAnim.playSequentially(revealAnim); + contentView.setVisibility(View.VISIBLE); + openAnim.start(); + }); + } + + protected void animateClose() { + endRect.setEmpty(); + if (openCloseAnimator != null) { + openCloseAnimator.cancel(); + } + final View contentView = getContentView(); + final AnimatorSet closeAnim = new AnimatorSet(); + // Rectangular reveal (reversed). + final ValueAnimator revealAnim = createOpenCloseOutlineProvider().createRevealAnimator(contentView, true); + revealAnim.setDuration(DURATION); + revealAnim.setInterpolator(revealInterpolator); + closeAnim.play(revealAnim); + + ValueAnimator fadeOut = ValueAnimator.ofFloat(contentView.getAlpha(), 0); + fadeOut.setDuration(DURATION); + fadeOut.setInterpolator(revealInterpolator); + fadeOut.addUpdateListener(anim -> { + float alpha = (float) anim.getAnimatedValue(); + contentView.setAlpha(revealAnim.isStarted() ? alpha : contentView.getAlpha()); + }); + closeAnim.playTogether(fadeOut); + closeAnim.addListener(exitAnimationListener); + openCloseAnimator = closeAnim; + closeAnim.start(); + } + + private RoundedRectRevealOutlineProvider createOpenCloseOutlineProvider() { + final View contentView = getContentView(); + final int radius = context.getResources().getDimensionPixelSize(R.dimen.dm_message_card_radius_small); + // Log.d(TAG, "createOpenCloseOutlineProvider: " + locationOnScreen(contentView) + " " + contentView.getMeasuredWidth() + " " + contentView + // .getMeasuredHeight()); + if (point == null) { + point = locationOnScreen(contentView); + } + final int left = location.x - point.x; + final int top = location.y - point.y; + startRect.set(left, top, left, top); + endRect.set(0, 0, contentView.getMeasuredWidth(), contentView.getMeasuredHeight()); + return new RoundedRectRevealOutlineProvider(radius, radius, startRect, endRect); + } + + public void dismiss() { + animateClose(); + } + + private View createContentView() { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final LayoutDirectItemOptionsBinding binding = LayoutDirectItemOptionsBinding.inflate(layoutInflater, null, false); + Pair firstLastEmojiView = null; + if (showReactions) { + firstLastEmojiView = addReactions(layoutInflater, binding.container); + } + if (hasOptions) { + View divider = null; + if (showReactions) { + if (firstLastEmojiView == null) { + throw new IllegalStateException("firstLastEmojiView is null even though reactions were added"); + } + // add divider if reactions were added + divider = addDivider(binding.container, + firstLastEmojiView.first.getId(), + firstLastEmojiView.first.getId(), + firstLastEmojiView.second.getId()); + ((ConstraintLayout.LayoutParams) firstLastEmojiView.first.getLayoutParams()).bottomToTop = divider.getId(); + } + addOptions(layoutInflater, binding.container, divider); + } + return binding.getRoot(); + } + + private Pair addReactions(final LayoutInflater layoutInflater, final ConstraintLayout container) { + final List reactions = reactionsManager.getReactions(); + AppCompatImageView prevSquareImageView = null; + View firstImageView = null; + View lastImageView = null; + for (int i = 0; i < reactions.size(); i++) { + final Emoji reaction = reactions.get(i); + final AppCompatImageView imageView = getEmojiImageView(); + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) imageView.getLayoutParams(); + if (i == 0 && !hasOptions) { + // only connect bottom to parent bottom if there are no options + layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; + } + if (i == 0) { + layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; + layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; + firstImageView = imageView; + layoutParams.setMargins(emojiMargin, emojiMargin, emojiMarginHalf, emojiMargin); + } else { + layoutParams.startToEnd = prevSquareImageView.getId(); + final ConstraintLayout.LayoutParams prevViewLayoutParams = (ConstraintLayout.LayoutParams) prevSquareImageView.getLayoutParams(); + prevViewLayoutParams.endToStart = imageView.getId(); + // always connect the other image view's top and bottom to the first image view top and bottom + layoutParams.topToTop = firstImageView.getId(); + layoutParams.bottomToBottom = firstImageView.getId(); + layoutParams.setMargins(emojiMarginHalf, emojiMargin, emojiMarginHalf, emojiMargin); + } + imageView.setImageDrawable(reaction.getDrawable()); + imageView.setOnClickListener(view -> { + if (onReactionClickListener != null) { + onReactionClickListener.onClick(reaction); + } + dismiss(); + }); + container.addView(imageView); + prevSquareImageView = imageView; + } + // add the + icon + if (prevSquareImageView != null) { + final AppCompatImageView imageView = getEmojiImageView(); + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) imageView.getLayoutParams(); + layoutParams.topToTop = firstImageView.getId(); + layoutParams.bottomToBottom = firstImageView.getId(); + layoutParams.startToEnd = prevSquareImageView.getId(); + final ConstraintLayout.LayoutParams prevViewLayoutParams = (ConstraintLayout.LayoutParams) prevSquareImageView.getLayoutParams(); + prevViewLayoutParams.endToStart = imageView.getId(); + layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; + layoutParams.setMargins(emojiMarginHalf - addAdjust, emojiMargin - addAdjust, emojiMargin - addAdjust, emojiMargin - addAdjust); + imageView.setImageResource(R.drawable.ic_add); + imageView.setOnClickListener(view -> { + if (onAddReactionListener != null) { + onAddReactionListener.onAdd(); + } + dismiss(); + }); + lastImageView = imageView; + container.addView(imageView); + } + return new Pair<>(firstImageView, lastImageView); + } + + @NonNull + private AppCompatImageView getEmojiImageView() { + final AppCompatImageView imageView = new AppCompatImageView(context); + final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(emojiSize, emojiSize); + imageView.setBackgroundResource(selectableItemBackgroundBorderless.resourceId); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + imageView.setId(SquareImageView.generateViewId()); + imageView.setLayoutParams(layoutParams); + return imageView; + } + + private void addOptions(final LayoutInflater layoutInflater, + final ConstraintLayout container, + @Nullable final View divider) { + View prevOptionView = null; + if (!showReactions) { + container.getLayoutParams().width = widthWithoutReactions; + } + for (int i = 0; i < options.size(); i++) { + final MenuItem menuItem = options.get(i); + final AppCompatTextView textView = getTextView(); + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) textView.getLayoutParams(); + layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; + layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; + if (i == 0) { + if (divider != null) { + layoutParams.topToBottom = divider.getId(); + ((ConstraintLayout.LayoutParams) divider.getLayoutParams()).bottomToTop = textView.getId(); + } else { + // if divider is null mean reactions were not added, so connect top to top of parent + layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; + layoutParams.topMargin = emojiMargin; // material design spec (https://material.io/components/menus#specs) + } + } else { + layoutParams.topToBottom = prevOptionView.getId(); + final ConstraintLayout.LayoutParams prevLayoutParams = (ConstraintLayout.LayoutParams) prevOptionView.getLayoutParams(); + prevLayoutParams.bottomToTop = textView.getId(); + } + if (i == options.size() - 1) { + layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; + layoutParams.bottomMargin = emojiMargin; // material design spec (https://material.io/components/menus#specs) + } + textView.setText(context.getString(menuItem.getTitleRes())); + textView.setOnClickListener(v -> { + if (onOptionSelectListener != null) { + onOptionSelectListener.onSelect(menuItem.getItemId(), menuItem.getCallback()); + } + dismiss(); + }); + container.addView(textView); + prevOptionView = textView; + } + } + + private AppCompatTextView getTextView() { + final AppCompatTextView textView = new AppCompatTextView(context); + textView.setId(AppCompatEditText.generateViewId()); + textView.setBackgroundResource(selectableItemBackground.resourceId); + textView.setGravity(Gravity.CENTER_VERTICAL); + textView.setPaddingRelative(optionPadding, 0, optionPadding, 0); + textView.setTextAppearance(context, R.style.TextAppearance_MaterialComponents_Body1); + final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + optionHeight); + textView.setLayoutParams(layoutParams); + return textView; + } + + private View addDivider(final ConstraintLayout container, + final int topViewId, + final int startViewId, + final int endViewId) { + final View dividerView = new View(context); + dividerView.setId(View.generateViewId()); + dividerView.setBackgroundResource(R.drawable.pref_list_divider_material); + final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + dividerHeight); + layoutParams.topToBottom = topViewId; + layoutParams.startToStart = startViewId; + layoutParams.endToEnd = endViewId; + dividerView.setLayoutParams(layoutParams); + container.addView(dividerView); + return dividerView; + } + + @NonNull + private Point locationOnScreen(@NonNull final View view) { + final int[] location = new int[2]; + view.getLocationOnScreen(location); + return new Point(location[0], location[1]); + } + + public static class MenuItem { + @IdRes + private final int itemId; + @StringRes + private final int titleRes; + + /** + * Callback function + */ + private final Function callback; + + public MenuItem(@IdRes final int itemId, @StringRes final int titleRes) { + this(itemId, titleRes, null); + } + + public MenuItem(@IdRes final int itemId, @StringRes final int titleRes, @Nullable final Function callback) { + this.itemId = itemId; + this.titleRes = titleRes; + this.callback = callback; + } + + public int getItemId() { + return itemId; + } + + public int getTitleRes() { + return titleRes; + } + + public Function getCallback() { + return callback; + } + } + + public interface OnOptionSelectListener { + void onSelect(int itemId, @Nullable Function callback); + } + + public interface OnReactionClickListener { + void onClick(Emoji emoji); + } + + public interface OnAddReactionClickListener { + void onAdd(); + } + + // @NonNull + // private Rect getGlobalVisibleRect(@NonNull final View view) { + // final Rect rect = new Rect(); + // view.getGlobalVisibleRect(rect); + // return rect; + // } + + // private void fixPopupLocation(@NonNull final PopupWindow popupWindow, @NonNull final Point desiredLocation) { + // popupWindow.getContentView().post(() -> { + // final Point actualLocation = locationOnScreen(popupWindow.getContentView()); + // + // if (!(actualLocation.x == desiredLocation.x && actualLocation.y == desiredLocation.y)) { + // final int differenceX = actualLocation.x - desiredLocation.x; + // final int differenceY = actualLocation.y - desiredLocation.y; + // + // final int fixedOffsetX; + // final int fixedOffsetY; + // + // if (actualLocation.x > desiredLocation.x) { + // fixedOffsetX = desiredLocation.x - differenceX; + // } else { + // fixedOffsetX = desiredLocation.x + differenceX; + // } + // + // if (actualLocation.y > desiredLocation.y) { + // fixedOffsetY = desiredLocation.y - differenceY; + // } else { + // fixedOffsetY = desiredLocation.y + differenceY; + // } + // + // popupWindow.update(fixedOffsetX, fixedOffsetY, DO_NOT_UPDATE_FLAG, DO_NOT_UPDATE_FLAG); + // } + // }); + // } +} + diff --git a/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java b/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java new file mode 100644 index 0000000..e119fcd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java @@ -0,0 +1,124 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.os.Handler; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class DirectItemFrameLayout extends FrameLayout { + private static final String TAG = DirectItemFrameLayout.class.getSimpleName(); + + private boolean longPressed = false; + private float touchX; + private float touchY; + private OnItemLongClickListener onItemLongClickListener; + private int touchSlop; + + private final Handler handler = new Handler(); + private final Runnable longPressRunnable = () -> { + longPressed = true; + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClick(this, touchX, touchY); + } + }; + private final Runnable longPressStartRunnable = () -> { + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClickStart(this); + } + }; + + public DirectItemFrameLayout(@NonNull final Context context) { + super(context); + init(context); + } + + public DirectItemFrameLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public DirectItemFrameLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + public DirectItemFrameLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context); + } + + private void init(final Context context) { + ViewConfiguration vc = ViewConfiguration.get(context); + touchSlop = vc.getScaledTouchSlop(); + } + + public void setOnItemLongClickListener(final OnItemLongClickListener onItemLongClickListener) { + this.onItemLongClickListener = onItemLongClickListener; + } + + @Override + public boolean dispatchTouchEvent(final MotionEvent ev) { + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + longPressed = false; + handler.postDelayed(longPressRunnable, ViewConfiguration.getLongPressTimeout()); + handler.postDelayed(longPressStartRunnable, ViewConfiguration.getTapTimeout()); + touchX = ev.getRawX(); + touchY = ev.getRawY(); + break; + case MotionEvent.ACTION_MOVE: + final float diffX = touchX - ev.getRawX(); + final float diffXAbs = Math.abs(diffX); + final boolean isMoved = diffXAbs > touchSlop || Math.abs(touchY - ev.getRawY()) > touchSlop; + if (longPressed || isMoved) { + handler.removeCallbacks(longPressStartRunnable); + handler.removeCallbacks(longPressRunnable); + if (!longPressed) { + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClickCancel(this); + } + } + // if (diffXAbs > touchSlop) { + // setTranslationX(-diffX); + // } + } + break; + case MotionEvent.ACTION_UP: + handler.removeCallbacks(longPressRunnable); + handler.removeCallbacks(longPressStartRunnable); + if (longPressed) { + return true; + } + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClickCancel(this); + } + break; + case MotionEvent.ACTION_CANCEL: + handler.removeCallbacks(longPressRunnable); + handler.removeCallbacks(longPressStartRunnable); + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClickCancel(this); + } + break; + } + final boolean dispatchTouchEvent = super.dispatchTouchEvent(ev); + if (ev.getAction() == MotionEvent.ACTION_DOWN && !dispatchTouchEvent) { + return true; + } + return dispatchTouchEvent; + } + + public interface OnItemLongClickListener { + void onLongClickStart(View view); + + void onLongClickCancel(View view); + + void onLongClick(View view, float x, float y); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/FixedImageView.java b/app/src/main/java/awais/instagrabber/customviews/FixedImageView.java new file mode 100755 index 0000000..302b1f9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/FixedImageView.java @@ -0,0 +1,25 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +public final class FixedImageView extends AppCompatImageView { + public FixedImageView(final Context context) { + super(context); + } + + public FixedImageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public FixedImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onMeasure(final int wMeasure, final int hMeasure) { + super.onMeasure(wMeasure, wMeasure); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/FormattedNumberTextView.java b/app/src/main/java/awais/instagrabber/customviews/FormattedNumberTextView.java new file mode 100644 index 0000000..06cdbf9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/FormattedNumberTextView.java @@ -0,0 +1,165 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.transition.ChangeBounds; +import androidx.transition.Transition; +import androidx.transition.TransitionManager; +import androidx.transition.TransitionSet; + +import java.time.Duration; + +import awais.instagrabber.customviews.helpers.ChangeText; +import awais.instagrabber.utils.NumberUtils; + +public class FormattedNumberTextView extends AppCompatTextView { + private static final String TAG = FormattedNumberTextView.class.getSimpleName(); + private static final Transition TRANSITION; + + private long number = Long.MIN_VALUE; + private boolean showAbbreviation = true; + private boolean animateChanges = false; + private boolean toggleOnClick = true; + private boolean autoToggleToAbbreviation = true; + private long autoToggleTimeoutMs = Duration.ofSeconds(2).toMillis(); + private boolean initDone = false; + + static { + final TransitionSet transitionSet = new TransitionSet(); + final ChangeText changeText = new ChangeText().setChangeBehavior(ChangeText.CHANGE_BEHAVIOR_OUT_IN); + transitionSet.addTransition(changeText).addTransition(new ChangeBounds()); + TRANSITION = transitionSet; + } + + + public FormattedNumberTextView(@NonNull final Context context) { + super(context); + init(); + } + + public FormattedNumberTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + init(); + } + + public FormattedNumberTextView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + if (initDone) return; + setupClickToggle(); + initDone = true; + } + + private void setupClickToggle() { + setOnClickListener(null); + } + + private OnClickListener getWrappedClickListener(@Nullable final OnClickListener l) { + if (!toggleOnClick) { + return l; + } + return v -> { + toggleAbbreviation(); + if (l != null) { + l.onClick(this); + } + }; + } + + public void setNumber(final long number) { + if (this.number == number) return; + this.number = number; + format(); + } + + public void clearNumber() { + if (number == Long.MIN_VALUE) return; + number = Long.MIN_VALUE; + format(); + } + + public void setShowAbbreviation(final boolean showAbbreviation) { + if (this.showAbbreviation && showAbbreviation) return; + this.showAbbreviation = showAbbreviation; + format(); + } + + public boolean isShowAbbreviation() { + return showAbbreviation; + } + + private void toggleAbbreviation() { + if (number == Long.MIN_VALUE) return; + setShowAbbreviation(!showAbbreviation); + } + + public void setToggleOnClick(final boolean toggleOnClick) { + this.toggleOnClick = toggleOnClick; + } + + public boolean isToggleOnClick() { + return toggleOnClick; + } + + public void setAutoToggleToAbbreviation(final boolean autoToggleToAbbreviation) { + this.autoToggleToAbbreviation = autoToggleToAbbreviation; + } + + public boolean isAutoToggleToAbbreviation() { + return autoToggleToAbbreviation; + } + + public void setAutoToggleTimeoutMs(final long autoToggleTimeoutMs) { + this.autoToggleTimeoutMs = autoToggleTimeoutMs; + } + + public long getAutoToggleTimeoutMs() { + return autoToggleTimeoutMs; + } + + public void setAnimateChanges(final boolean animateChanges) { + this.animateChanges = animateChanges; + } + + public boolean isAnimateChanges() { + return animateChanges; + } + + @Override + public void setOnClickListener(@Nullable final OnClickListener l) { + super.setOnClickListener(getWrappedClickListener(l)); + } + + private void format() { + post(() -> { + if (animateChanges) { + try { + TransitionManager.beginDelayedTransition((ViewGroup) getParent(), TRANSITION); + } catch (Exception e) { + Log.e(TAG, "format: ", e); + } + } + if (number == Long.MIN_VALUE) { + setText(null); + return; + } + if (showAbbreviation) { + setText(NumberUtils.abbreviate(number, null)); + return; + } + setText(String.valueOf(number)); + if (autoToggleToAbbreviation) { + getHandler().postDelayed(() -> setShowAbbreviation(true), autoToggleTimeoutMs); + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java b/app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java new file mode 100644 index 0000000..3e08924 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java @@ -0,0 +1,246 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.WindowInsetsAnimation; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.NestedScrollingParent3; +import androidx.core.view.NestedScrollingParentHelper; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.Arrays; + +import awais.instagrabber.customviews.helpers.SimpleImeAnimationController; +import awais.instagrabber.utils.ViewUtils; + +import static androidx.core.view.ViewCompat.TYPE_TOUCH; + +public final class InsetsAnimationLinearLayout extends LinearLayout implements NestedScrollingParent3 { + private final NestedScrollingParentHelper nestedScrollingParentHelper = new NestedScrollingParentHelper(this); + private final SimpleImeAnimationController imeAnimController = new SimpleImeAnimationController(); + private final int[] tempIntArray2 = new int[2]; + private final int[] startViewLocation = new int[2]; + + private View currentNestedScrollingChild; + private int dropNextY; + private boolean scrollImeOffScreenWhenVisible = true; + private boolean scrollImeOnScreenWhenNotVisible = true; + private boolean scrollImeOffScreenWhenVisibleOnFling = false; + private boolean scrollImeOnScreenWhenNotVisibleOnFling = false; + + public InsetsAnimationLinearLayout(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public InsetsAnimationLinearLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public final boolean getScrollImeOffScreenWhenVisible() { + return scrollImeOffScreenWhenVisible; + } + + public final void setScrollImeOffScreenWhenVisible(boolean scrollImeOffScreenWhenVisible) { + this.scrollImeOffScreenWhenVisible = scrollImeOffScreenWhenVisible; + } + + public final boolean getScrollImeOnScreenWhenNotVisible() { + return scrollImeOnScreenWhenNotVisible; + } + + public final void setScrollImeOnScreenWhenNotVisible(boolean scrollImeOnScreenWhenNotVisible) { + this.scrollImeOnScreenWhenNotVisible = scrollImeOnScreenWhenNotVisible; + } + + public boolean getScrollImeOffScreenWhenVisibleOnFling() { + return scrollImeOffScreenWhenVisibleOnFling; + } + + public void setScrollImeOffScreenWhenVisibleOnFling(final boolean scrollImeOffScreenWhenVisibleOnFling) { + this.scrollImeOffScreenWhenVisibleOnFling = scrollImeOffScreenWhenVisibleOnFling; + } + + public boolean getScrollImeOnScreenWhenNotVisibleOnFling() { + return scrollImeOnScreenWhenNotVisibleOnFling; + } + + public void setScrollImeOnScreenWhenNotVisibleOnFling(final boolean scrollImeOnScreenWhenNotVisibleOnFling) { + this.scrollImeOnScreenWhenNotVisibleOnFling = scrollImeOnScreenWhenNotVisibleOnFling; + } + + public SimpleImeAnimationController getImeAnimController() { + return imeAnimController; + } + + @Override + public boolean onStartNestedScroll(@NonNull final View child, + @NonNull final View target, + final int axes, + final int type) { + return (axes & SCROLL_AXIS_VERTICAL) != 0 && type == TYPE_TOUCH; + } + + @Override + public void onNestedScrollAccepted(@NonNull final View child, + @NonNull final View target, + final int axes, + final int type) { + nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type); + currentNestedScrollingChild = child; + } + + @Override + public void onNestedPreScroll(@NonNull final View target, + final int dx, + final int dy, + @NonNull final int[] consumed, + final int type) { + if (imeAnimController.isInsetAnimationRequestPending()) { + consumed[0] = dx; + consumed[1] = dy; + } else { + int deltaY = dy; + if (dropNextY != 0) { + consumed[1] = dropNextY; + deltaY = dy - dropNextY; + dropNextY = 0; + } + + if (deltaY < 0) { + if (imeAnimController.isInsetAnimationInProgress()) { + consumed[1] -= imeAnimController.insetBy(-deltaY); + } else if (scrollImeOffScreenWhenVisible && !imeAnimController.isInsetAnimationRequestPending()) { + WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); + if (rootWindowInsets != null) { + if (rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + startControlRequest(); + consumed[1] = deltaY; + } + } + } + } + + } + } + + @Override + public void onNestedScroll(@NonNull final View target, + final int dxConsumed, + final int dyConsumed, + final int dxUnconsumed, + final int dyUnconsumed, + final int type, + @NonNull final int[] consumed) { + if (dyUnconsumed > 0) { + if (imeAnimController.isInsetAnimationInProgress()) { + consumed[1] = -imeAnimController.insetBy(-dyUnconsumed); + } else if (scrollImeOnScreenWhenNotVisible && !imeAnimController.isInsetAnimationRequestPending()) { + WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); + if (rootWindowInsets != null) { + if (!rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + startControlRequest(); + consumed[1] = dyUnconsumed; + } + } + } + } + + } + + @Override + public boolean onNestedFling(@NonNull final View target, + final float velocityX, + final float velocityY, + final boolean consumed) { + if (imeAnimController.isInsetAnimationInProgress()) { + imeAnimController.animateToFinish(velocityY); + return true; + } else { + boolean imeVisible = false; + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); + if (rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + imeVisible = true; + } + if (velocityY > 0 && scrollImeOnScreenWhenNotVisibleOnFling && !imeVisible) { + imeAnimController.startAndFling(this, velocityY); + return true; + } else if (velocityY < 0 && scrollImeOffScreenWhenVisibleOnFling && imeVisible) { + imeAnimController.startAndFling(this, velocityY); + return true; + } else { + return false; + } + } + } + + @Override + public void onStopNestedScroll(@NonNull final View target, final int type) { + nestedScrollingParentHelper.onStopNestedScroll(target, type); + if (imeAnimController.isInsetAnimationInProgress() && !imeAnimController.isInsetAnimationFinishing()) { + imeAnimController.animateToFinish(null); + } + reset(); + } + + @Override + public void dispatchWindowInsetsAnimationPrepare(@NonNull final WindowInsetsAnimation animation) { + super.dispatchWindowInsetsAnimationPrepare(animation); + ViewUtils.suppressLayoutCompat(this, false); + } + + private void startControlRequest() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return; + } + ViewUtils.suppressLayoutCompat(this, true); + if (currentNestedScrollingChild != null) { + currentNestedScrollingChild.getLocationInWindow(startViewLocation); + } + imeAnimController.startControlRequest(this, windowInsetsAnimationControllerCompat -> onControllerReady()); + } + + private void onControllerReady() { + if (currentNestedScrollingChild != null) { + imeAnimController.insetBy(0); + int[] location = tempIntArray2; + currentNestedScrollingChild.getLocationInWindow(location); + dropNextY = location[1] - startViewLocation[1]; + } + + } + + private void reset() { + dropNextY = 0; + Arrays.fill(startViewLocation, 0); + ViewUtils.suppressLayoutCompat(this, false); + } + + @Override + public void onNestedScrollAccepted(@NonNull final View child, + @NonNull final View target, + final int axes) { + onNestedScrollAccepted(child, target, axes, TYPE_TOUCH); + } + + @Override + public void onNestedScroll(@NonNull final View target, + final int dxConsumed, + final int dyConsumed, + final int dxUnconsumed, + final int dyUnconsumed, + final int type) { + onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, tempIntArray2); + } + + @Override + public void onStopNestedScroll(@NonNull final View target) { + onStopNestedScroll(target, TYPE_TOUCH); + } +} + diff --git a/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java new file mode 100644 index 0000000..13a93e4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java @@ -0,0 +1,33 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.WindowInsets; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +public class InsetsNotifyingCoordinatorLayout extends CoordinatorLayout { + + public InsetsNotifyingCoordinatorLayout(@NonNull final Context context) { + super(context); + } + + public InsetsNotifyingCoordinatorLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public InsetsNotifyingCoordinatorLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public WindowInsets onApplyWindowInsets(WindowInsets insets) { + int childCount = getChildCount(); + for (int index = 0; index < childCount; index++) { + getChildAt(index).dispatchApplyWindowInsets(insets); + } + return insets; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java new file mode 100644 index 0000000..b2faa4e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java @@ -0,0 +1,35 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.WindowInsets; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +public class InsetsNotifyingLinearLayout extends LinearLayout { + public InsetsNotifyingLinearLayout(final Context context) { + super(context); + } + + public InsetsNotifyingLinearLayout(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public InsetsNotifyingLinearLayout(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public InsetsNotifyingLinearLayout(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public WindowInsets onApplyWindowInsets(WindowInsets insets) { + int childCount = getChildCount(); + for (int index = 0; index < childCount; index++) { + getChildAt(index).dispatchApplyWindowInsets(insets); + } + return insets; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/KeyNotifyingEmojiEditText.java b/app/src/main/java/awais/instagrabber/customviews/KeyNotifyingEmojiEditText.java new file mode 100644 index 0000000..e64a4d3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/KeyNotifyingEmojiEditText.java @@ -0,0 +1,44 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; + +import androidx.emoji.widget.EmojiEditText; + +public class KeyNotifyingEmojiEditText extends EmojiEditText { + private OnKeyEventListener onKeyEventListener; + + public KeyNotifyingEmojiEditText(final Context context) { + super(context); + } + + public KeyNotifyingEmojiEditText(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public KeyNotifyingEmojiEditText(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public KeyNotifyingEmojiEditText(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public boolean onKeyPreIme(final int keyCode, final KeyEvent event) { + if (onKeyEventListener != null) { + final boolean listenerResult = onKeyEventListener.onKeyPreIme(keyCode, event); + if (listenerResult) return true; + } + return super.onKeyPreIme(keyCode, event); + } + + public void setOnKeyEventListener(final OnKeyEventListener onKeyEventListener) { + this.onKeyEventListener = onKeyEventListener; + } + + public interface OnKeyEventListener { + boolean onKeyPreIme(int keyCode, KeyEvent keyEvent); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/MouseDrawer.java b/app/src/main/java/awais/instagrabber/customviews/MouseDrawer.java new file mode 100755 index 0000000..8491be2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/MouseDrawer.java @@ -0,0 +1,986 @@ +package awais.instagrabber.customviews; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.GravityCompat; +import androidx.core.view.ViewCompat; +import androidx.customview.view.AbsSavedState; +import androidx.customview.widget.ViewDragHelper; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import awais.instagrabber.BuildConfig; + +// exactly same as the LayoutDrawer with some edits +@SuppressLint("RtlHardcoded") +public class MouseDrawer extends ViewGroup { + @IntDef({ViewDragHelper.STATE_IDLE, ViewDragHelper.STATE_DRAGGING, ViewDragHelper.STATE_SETTLING}) + @Retention(RetentionPolicy.SOURCE) + private @interface State {} + + @IntDef(value = {Gravity.NO_GRAVITY, Gravity.LEFT, Gravity.RIGHT, GravityCompat.START, GravityCompat.END}, flag = true) + @Retention(RetentionPolicy.SOURCE) + public @interface EdgeGravity {} + + //////////////////////////////////////////////////////////////////////////////////// + private static final boolean CHILDREN_DISALLOW_INTERCEPT = true; + //////////////////////////////////////////////////////////////////////////////////// + private final ArrayList mNonDrawerViews = new ArrayList<>(); + private final ViewDragHelper mLeftDragger, mRightDragger; + private boolean mInLayout, mFirstLayout = true; + private float mDrawerElevation, mInitialMotionX, mInitialMotionY; + private int mDrawerState; + private List mListeners; + private Matrix mChildInvertedMatrix; + private Rect mChildHitRect; + + public interface DrawerListener { + void onDrawerSlide(final View drawerView, @EdgeGravity final int gravity, final float slideOffset); + default void onDrawerOpened(final View drawerView, @EdgeGravity final int gravity) {} + default void onDrawerClosed(final View drawerView, @EdgeGravity final int gravity) {} + default void onDrawerStateChanged() {} + } + + public MouseDrawer(@NonNull final Context context) { + this(context, null); + } + + public MouseDrawer(@NonNull final Context context, @Nullable final AttributeSet attrs) { + this(context, attrs, 0); + } + + public MouseDrawer(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + + final float density = getResources().getDisplayMetrics().density; + this.mDrawerElevation = 10 * density; + + final float touchSlopSensitivity = 0.5f; // was 1.0f + final float minFlingVelocity = 400 /* dips per second */ * density; + + final ViewDragCallback mLeftCallback = new ViewDragCallback(Gravity.LEFT); + this.mLeftDragger = ViewDragHelper.create(this, touchSlopSensitivity, mLeftCallback); + this.mLeftDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT); + this.mLeftDragger.setMinVelocity(minFlingVelocity); + + final ViewDragCallback mRightCallback = new ViewDragCallback(Gravity.RIGHT); + this.mRightDragger = ViewDragHelper.create(this, touchSlopSensitivity, mRightCallback); + this.mRightDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_RIGHT); + this.mRightDragger.setMinVelocity(minFlingVelocity); + + try { + final Field edgeSizeField = ViewDragHelper.class.getDeclaredField("mEdgeSize"); + if (!edgeSizeField.isAccessible()) edgeSizeField.setAccessible(true); + final int widthPixels = getResources().getDisplayMetrics().widthPixels; // whole screen + edgeSizeField.set(this.mLeftDragger, widthPixels / 2); + edgeSizeField.set(this.mRightDragger, widthPixels / 2); + } catch (final Exception e) { + if (BuildConfig.DEBUG) Log.e("AWAISKING_APP", "", e); + } + + mLeftCallback.setDragger(mLeftDragger); + mRightCallback.setDragger(mRightDragger); + + setFocusableInTouchMode(true); + //setMotionEventSplittingEnabled(false); + } + + public void setDrawerElevation(final float elevation) { + mDrawerElevation = elevation; + for (int i = 0; i < getChildCount(); i++) { + final View child = getChildAt(i); + if (isDrawerView(child)) ViewCompat.setElevation(child, mDrawerElevation); + } + } + + public float getDrawerElevation() { + return Build.VERSION.SDK_INT >= 21 ? mDrawerElevation : 0f; + } + + public void addDrawerListener(@NonNull final DrawerListener listener) { + if (mListeners == null) mListeners = new ArrayList<>(); + mListeners.add(listener); + } + + private boolean isInBoundsOfChild(final float x, final float y, final View child) { + if (mChildHitRect == null) mChildHitRect = new Rect(); + child.getHitRect(mChildHitRect); + return mChildHitRect.contains((int) x, (int) y); + } + + private boolean dispatchTransformedGenericPointerEvent(final MotionEvent event, @NonNull final View child) { + final boolean handled; + final Matrix childMatrix = child.getMatrix(); + if (!childMatrix.isIdentity()) { + final MotionEvent transformedEvent = getTransformedMotionEvent(event, child); + handled = child.dispatchGenericMotionEvent(transformedEvent); + transformedEvent.recycle(); + } else { + final float offsetX = getScrollX() - child.getLeft(); + final float offsetY = getScrollY() - child.getTop(); + event.offsetLocation(offsetX, offsetY); + handled = child.dispatchGenericMotionEvent(event); + event.offsetLocation(-offsetX, -offsetY); + } + return handled; + } + + @NonNull + private MotionEvent getTransformedMotionEvent(final MotionEvent event, @NonNull final View child) { + final float offsetX = getScrollX() - child.getLeft(); + final float offsetY = getScrollY() - child.getTop(); + final MotionEvent transformedEvent = MotionEvent.obtain(event); + transformedEvent.offsetLocation(offsetX, offsetY); + final Matrix childMatrix = child.getMatrix(); + if (!childMatrix.isIdentity()) { + if (mChildInvertedMatrix == null) mChildInvertedMatrix = new Matrix(); + childMatrix.invert(mChildInvertedMatrix); + transformedEvent.transform(mChildInvertedMatrix); + } + return transformedEvent; + } + + void updateDrawerState(@State final int activeState, final View activeDrawer) { + final int leftState = mLeftDragger.getViewDragState(); + final int rightState = mRightDragger.getViewDragState(); + + final int state; + if (leftState == ViewDragHelper.STATE_DRAGGING || rightState == ViewDragHelper.STATE_DRAGGING) + state = ViewDragHelper.STATE_DRAGGING; + else if (leftState == ViewDragHelper.STATE_SETTLING || rightState == ViewDragHelper.STATE_SETTLING) + state = ViewDragHelper.STATE_SETTLING; + else state = ViewDragHelper.STATE_IDLE; + + if (activeDrawer != null && activeState == ViewDragHelper.STATE_IDLE) { + final LayoutParams lp = (LayoutParams) activeDrawer.getLayoutParams(); + if (lp.onScreen == 0) dispatchOnDrawerClosed(activeDrawer); + else if (lp.onScreen == 1) dispatchOnDrawerOpened(activeDrawer); + } + + if (state != mDrawerState) { + mDrawerState = state; + + if (mListeners != null) { + final int listenerCount = mListeners.size(); + for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerStateChanged(); + } + } + } + + void dispatchOnDrawerClosed(@NonNull final View drawerView) { + final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); + if ((lp.openState & LayoutParams.FLAG_IS_OPENED) == 1) { + lp.openState = 0; + + if (mListeners != null) { + final int listenerCount = mListeners.size(); + for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerClosed(drawerView, lp.gravity); + } + } + } + + void dispatchOnDrawerOpened(@NonNull final View drawerView) { + final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); + if ((lp.openState & LayoutParams.FLAG_IS_OPENED) == 0) { + lp.openState = LayoutParams.FLAG_IS_OPENED; + if (mListeners != null) { + final int listenerCount = mListeners.size(); + for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerOpened(drawerView, lp.gravity); + } + } + } + + void setDrawerViewOffset(@NonNull final View drawerView, final float slideOffset) { + final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); + if (slideOffset != lp.onScreen) { + lp.onScreen = slideOffset; + + if (mListeners != null) { + final int listenerCount = mListeners.size(); + for (int i = listenerCount - 1; i >= 0; i--) + mListeners.get(i).onDrawerSlide(drawerView, lp.gravity, slideOffset); + } + } + } + + float getDrawerViewOffset(@NonNull final View drawerView) { + return ((LayoutParams) drawerView.getLayoutParams()).onScreen; + } + + int getDrawerViewAbsoluteGravity(@NonNull final View drawerView) { + final int gravity = ((LayoutParams) drawerView.getLayoutParams()).gravity; + return GravityCompat.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(this)); + } + + boolean checkDrawerViewAbsoluteGravity(final View drawerView, final int checkFor) { + final int absGravity = getDrawerViewAbsoluteGravity(drawerView); + return (absGravity & checkFor) == checkFor; + } + + void moveDrawerToOffset(final View drawerView, final float slideOffset) { + final float oldOffset = getDrawerViewOffset(drawerView); + final int width = drawerView.getWidth(); + final int oldPos = (int) (width * oldOffset); + final int newPos = (int) (width * slideOffset); + final int dx = newPos - oldPos; + + drawerView.offsetLeftAndRight(checkDrawerViewAbsoluteGravity(drawerView, Gravity.LEFT) ? dx : -dx); + setDrawerViewOffset(drawerView, slideOffset); + } + + public View findOpenDrawer() { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams childLp = (LayoutParams) child.getLayoutParams(); + if ((childLp.openState & LayoutParams.FLAG_IS_OPENED) == 1) return child; + } + return null; + } + + public View findDrawerWithGravity(final int gravity) { + final int absHorizGravity = GravityCompat.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(this)) & Gravity.HORIZONTAL_GRAVITY_MASK; + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final int childAbsGravity = getDrawerViewAbsoluteGravity(child); + if ((childAbsGravity & Gravity.HORIZONTAL_GRAVITY_MASK) == absHorizGravity) return child; + } + return null; + } + + @NonNull + static String gravityToString(@EdgeGravity final int gravity) { + if ((gravity & Gravity.LEFT) == Gravity.LEFT) return "LEFT"; + if ((gravity & Gravity.RIGHT) == Gravity.RIGHT) return "RIGHT"; + return Integer.toHexString(gravity); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mFirstLayout = true; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mFirstLayout = true; + } + + @SuppressLint("WrongConstant") + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int widthSize = MeasureSpec.getSize(widthMeasureSpec); + final int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + setMeasuredDimension(widthSize, heightSize); + + boolean hasDrawerOnLeftEdge = false; + boolean hasDrawerOnRightEdge = false; + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + if (child.getVisibility() != GONE) { + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + if (isContentView(child)) { + // Content views get measured at exactly the layout's size. + final int contentWidthSpec = MeasureSpec.makeMeasureSpec(widthSize - lp.leftMargin - lp.rightMargin, MeasureSpec.EXACTLY); + final int contentHeightSpec = MeasureSpec.makeMeasureSpec(heightSize - lp.topMargin - lp.bottomMargin, MeasureSpec.EXACTLY); + child.measure(contentWidthSpec, contentHeightSpec); + + } else if (isDrawerView(child)) { + if (Build.VERSION.SDK_INT >= 21 && ViewCompat.getElevation(child) != mDrawerElevation) + ViewCompat.setElevation(child, mDrawerElevation); + final int childGravity = getDrawerViewAbsoluteGravity(child) & Gravity.HORIZONTAL_GRAVITY_MASK; + + final boolean isLeftEdgeDrawer = (childGravity == Gravity.LEFT); + if (isLeftEdgeDrawer && hasDrawerOnLeftEdge || !isLeftEdgeDrawer && hasDrawerOnRightEdge) + throw new IllegalStateException("Child drawer has absolute gravity " + gravityToString(childGravity) + + " but this MouseDrawer already has a drawer view along that edge"); + + if (isLeftEdgeDrawer) hasDrawerOnLeftEdge = true; + else hasDrawerOnRightEdge = true; + + final int drawerWidthSpec = getChildMeasureSpec(widthMeasureSpec, lp.leftMargin + lp.rightMargin, lp.width); + final int drawerHeightSpec = getChildMeasureSpec(heightMeasureSpec, lp.topMargin + lp.bottomMargin, lp.height); + child.measure(drawerWidthSpec, drawerHeightSpec); + } else + throw new IllegalStateException("Child " + child + " at index " + i + + " does not have a valid layout_gravity - must be Gravity.LEFT, Gravity.RIGHT or Gravity.NO_GRAVITY"); + } + } + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { + mInLayout = true; + final int width = right - left; + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + if (child.getVisibility() != GONE) { + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + if (isContentView(child)) { + child.layout(lp.leftMargin, lp.topMargin, lp.leftMargin + child.getMeasuredWidth(), + lp.topMargin + child.getMeasuredHeight()); + + } else { // Drawer, if it wasn't onMeasure would have thrown an exception. + final int childWidth = child.getMeasuredWidth(); + final int childHeight = child.getMeasuredHeight(); + final int childLeft; + final float newOffset; + + if (checkDrawerViewAbsoluteGravity(child, Gravity.LEFT)) { + childLeft = -childWidth + (int) (childWidth * lp.onScreen); + newOffset = (float) (childWidth + childLeft) / childWidth; + } else { // Right; onMeasure checked for us. + childLeft = width - (int) (childWidth * lp.onScreen); + newOffset = (float) (width - childLeft) / childWidth; + } + + final boolean changeOffset = newOffset != lp.onScreen; + + final int vgrav = lp.gravity & Gravity.VERTICAL_GRAVITY_MASK; + switch (vgrav) { + default: + case Gravity.TOP: + child.layout(childLeft, lp.topMargin, childLeft + childWidth, lp.topMargin + childHeight); + break; + + case Gravity.BOTTOM: { + final int height = bottom - top; + child.layout(childLeft, height - lp.bottomMargin - child.getMeasuredHeight(), + childLeft + childWidth, height - lp.bottomMargin); + break; + } + + case Gravity.CENTER_VERTICAL: { + final int height = bottom - top; + int childTop = (height - childHeight) / 2; + + if (childTop < lp.topMargin) childTop = lp.topMargin; + else if (childTop + childHeight > height - lp.bottomMargin) + childTop = height - lp.bottomMargin - childHeight; + + child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); + break; + } + } + + if (changeOffset) setDrawerViewOffset(child, newOffset); + + final int newVisibility = lp.onScreen > 0 ? VISIBLE : INVISIBLE; + if (child.getVisibility() != newVisibility) child.setVisibility(newVisibility); + } + } + } + mInLayout = false; + mFirstLayout = false; + } + + @Override + public void requestLayout() { + if (!mInLayout) super.requestLayout(); + } + + @Override + public void computeScroll() { + final boolean leftDraggerSettling = mLeftDragger.continueSettling(true); + final boolean rightDraggerSettling = mRightDragger.continueSettling(true); + if (leftDraggerSettling || rightDraggerSettling) postInvalidateOnAnimation(); + } + + private static boolean hasOpaqueBackground(@NonNull final View v) { + final Drawable bg = v.getBackground(); + if (bg != null) return bg.getOpacity() == PixelFormat.OPAQUE; + return false; + } + + @Override + protected boolean drawChild(@NonNull final Canvas canvas, final View child, final long drawingTime) { + final int height = getHeight(); + final boolean drawingContent = isContentView(child); + int clipLeft = 0, clipRight = getWidth(); + + final int restoreCount = canvas.save(); + if (drawingContent) { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View v = getChildAt(i); + if (v != child && v.getVisibility() == VISIBLE && hasOpaqueBackground(v) && isDrawerView(v) && v.getHeight() >= height) { + if (checkDrawerViewAbsoluteGravity(v, Gravity.LEFT)) { + final int vright = v.getRight(); + if (vright > clipLeft) clipLeft = vright; + } else { + final int vleft = v.getLeft(); + if (vleft < clipRight) clipRight = vleft; + } + } + } + canvas.clipRect(clipLeft, 0, clipRight, getHeight()); + } + + final boolean result = super.drawChild(canvas, child, drawingTime); + canvas.restoreToCount(restoreCount); + + return result; + } + + boolean isContentView(@NonNull final View child) { + return ((LayoutParams) child.getLayoutParams()).gravity == Gravity.NO_GRAVITY; + } + + boolean isDrawerView(@NonNull final View child) { + final int gravity = ((LayoutParams) child.getLayoutParams()).gravity; + final int absGravity = GravityCompat.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(child)); + return (absGravity & Gravity.LEFT) != 0 || (absGravity & Gravity.RIGHT) != 0; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull final MotionEvent ev) { + final int action = ev.getActionMasked(); + + // "|" used deliberately here; both methods should be invoked. + final boolean interceptForDrag = mLeftDragger.shouldInterceptTouchEvent(ev) | mRightDragger.shouldInterceptTouchEvent(ev); + + switch (action) { + case MotionEvent.ACTION_DOWN: + mInitialMotionX = ev.getX(); + mInitialMotionY = ev.getY(); + break; + + case MotionEvent.ACTION_MOVE: + mLeftDragger.checkTouchSlop(ViewDragHelper.DIRECTION_ALL); + break; + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + closeDrawers(true); + } + + return interceptForDrag || hasPeekingDrawer(); + } + + @Override + public boolean dispatchGenericMotionEvent(@NonNull final MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_CLASS_POINTER) == 0 || event.getAction() == MotionEvent.ACTION_HOVER_EXIT) + return super.dispatchGenericMotionEvent(event); + + final int childrenCount = getChildCount(); + if (childrenCount != 0) { + final float x = event.getX(); + final float y = event.getY(); + + // Walk through children from top to bottom. + for (int i = childrenCount - 1; i >= 0; i--) { + final View child = getChildAt(i); + if (isInBoundsOfChild(x, y, child) && !isContentView(child) && dispatchTransformedGenericPointerEvent(event, child)) + return true; + } + } + + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(final MotionEvent ev) { + mLeftDragger.processTouchEvent(ev); + mRightDragger.processTouchEvent(ev); + + final int action = ev.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + mInitialMotionX = ev.getX(); + mInitialMotionY = ev.getY(); + break; + + case MotionEvent.ACTION_UP: + final float x = ev.getX(); + final float y = ev.getY(); + + boolean peekingOnly = true; + final View touchedView = mLeftDragger.findTopChildUnder((int) x, (int) y); + if (touchedView != null && isContentView(touchedView)) { + final float dx = x - mInitialMotionX; + final float dy = y - mInitialMotionY; + final int slop = mLeftDragger.getTouchSlop(); + if (dx * dx + dy * dy < slop * slop) { + // Taps close a dimmed open drawer but only if it isn't locked open. + final View openDrawer = findOpenDrawer(); + if (openDrawer != null) peekingOnly = false; + } + } + closeDrawers(peekingOnly); + break; + + case MotionEvent.ACTION_CANCEL: + closeDrawers(true); + break; + } + + return true; + } + + @Override + public void requestDisallowInterceptTouchEvent(final boolean disallowIntercept) { + if (CHILDREN_DISALLOW_INTERCEPT || (!mLeftDragger.isEdgeTouched(ViewDragHelper.EDGE_LEFT) && !mRightDragger.isEdgeTouched(ViewDragHelper.EDGE_RIGHT))) + super.requestDisallowInterceptTouchEvent(disallowIntercept); + if (disallowIntercept) closeDrawers(true); + } + + public void closeDrawers() { + closeDrawers(false); + } + + void closeDrawers(final boolean peekingOnly) { + boolean needsInvalidate = false; + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + if (isDrawerView(child) && (!peekingOnly || lp.isPeeking)) { + final int childWidth = child.getWidth(); + + if (checkDrawerViewAbsoluteGravity(child, Gravity.LEFT)) + needsInvalidate |= mLeftDragger.smoothSlideViewTo(child, -childWidth, child.getTop()); + else + needsInvalidate |= mRightDragger.smoothSlideViewTo(child, getWidth(), child.getTop()); + + lp.isPeeking = false; + } + } + + if (needsInvalidate) invalidate(); + } + + public void openDrawer(@NonNull final View drawerView, final boolean animate) { + if (isDrawerView(drawerView)) { + final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); + + if (mFirstLayout) { + lp.onScreen = 1.f; + lp.openState = LayoutParams.FLAG_IS_OPENED; + } else if (animate) { + lp.openState |= LayoutParams.FLAG_IS_OPENING; + + if (checkDrawerViewAbsoluteGravity(drawerView, Gravity.LEFT)) + mLeftDragger.smoothSlideViewTo(drawerView, 0, drawerView.getTop()); + else + mRightDragger.smoothSlideViewTo(drawerView, getWidth() - drawerView.getWidth(), drawerView.getTop()); + } else { + moveDrawerToOffset(drawerView, 1.f); + updateDrawerState(ViewDragHelper.STATE_IDLE, drawerView); + drawerView.setVisibility(VISIBLE); + } + + invalidate(); + return; + } + throw new IllegalArgumentException("View " + drawerView + " is not a sliding drawer"); + } + + public void openDrawer(@NonNull final View drawerView) { + openDrawer(drawerView, true); + } + + // public void openDrawer(@EdgeGravity final int gravity, final boolean animate) { + // final View drawerView = findDrawerWithGravity(gravity); + // if (drawerView != null) openDrawer(drawerView, animate); + // else throw new IllegalArgumentException("No drawer view found with gravity " + gravityToString(gravity)); + // } + + // public void openDrawer(@EdgeGravity final int gravity) { + // openDrawer(gravity, true); + // } + + public void closeDrawer(@NonNull final View drawerView) { + closeDrawer(drawerView, true); + } + + public void closeDrawer(@NonNull final View drawerView, final boolean animate) { + if (isDrawerView(drawerView)) { + final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); + if (mFirstLayout) { + lp.onScreen = 0.f; + lp.openState = 0; + } else if (animate) { + lp.openState |= LayoutParams.FLAG_IS_CLOSING; + + if (checkDrawerViewAbsoluteGravity(drawerView, Gravity.LEFT)) + mLeftDragger.smoothSlideViewTo(drawerView, -drawerView.getWidth(), drawerView.getTop()); + else + mRightDragger.smoothSlideViewTo(drawerView, getWidth(), drawerView.getTop()); + } else { + moveDrawerToOffset(drawerView, 0.f); + updateDrawerState(ViewDragHelper.STATE_IDLE, drawerView); + drawerView.setVisibility(INVISIBLE); + } + invalidate(); + } else throw new IllegalArgumentException("View " + drawerView + " is not a sliding drawer"); + } + + // public void closeDrawer(@EdgeGravity final int gravity) { + // closeDrawer(gravity, true); + // } + + // public void closeDrawer(@EdgeGravity final int gravity, final boolean animate) { + // final View drawerView = findDrawerWithGravity(gravity); + // if (drawerView != null) closeDrawer(drawerView, animate); + // else throw new IllegalArgumentException("No drawer view found with gravity " + gravityToString(gravity)); + // } + + public boolean isDrawerOpen(@NonNull final View drawer) { + if (isDrawerView(drawer)) return (((LayoutParams) drawer.getLayoutParams()).openState & LayoutParams.FLAG_IS_OPENED) == 1; + else throw new IllegalArgumentException("View " + drawer + " is not a drawer"); + } + + // public boolean isDrawerOpen(@EdgeGravity final int drawerGravity) { + // final View drawerView = findDrawerWithGravity(drawerGravity); + // return drawerView != null && isDrawerOpen(drawerView); + // } + + public boolean isDrawerVisible(@NonNull final View drawer) { + if (isDrawerView(drawer)) return ((LayoutParams) drawer.getLayoutParams()).onScreen > 0; + throw new IllegalArgumentException("View " + drawer + " is not a drawer"); + } + + // public boolean isDrawerVisible(@EdgeGravity final int drawerGravity) { + // final View drawerView = findDrawerWithGravity(drawerGravity); + // return drawerView != null && isDrawerVisible(drawerView); + // } + + private boolean hasPeekingDrawer() { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams(); + if (lp.isPeeking) return true; + } + return false; + } + + @Override + protected ViewGroup.LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + } + + @Override + protected ViewGroup.LayoutParams generateLayoutParams(final ViewGroup.LayoutParams params) { + return params instanceof LayoutParams ? new LayoutParams((LayoutParams) params) : + params instanceof ViewGroup.MarginLayoutParams ? new LayoutParams((MarginLayoutParams) params) : new LayoutParams(params); + } + + @Override + protected boolean checkLayoutParams(final ViewGroup.LayoutParams params) { + return params instanceof LayoutParams && super.checkLayoutParams(params); + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(final AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + public void addFocusables(final ArrayList views, final int direction, final int focusableMode) { + if (getDescendantFocusability() != FOCUS_BLOCK_DESCENDANTS) { + final int childCount = getChildCount(); + boolean isDrawerOpen = false; + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (!isDrawerView(child)) mNonDrawerViews.add(child); + else if (isDrawerOpen(child)) { + isDrawerOpen = true; + child.addFocusables(views, direction, focusableMode); + } + } + + if (!isDrawerOpen) { + final int nonDrawerViewsCount = mNonDrawerViews.size(); + for (int i = 0; i < nonDrawerViewsCount; ++i) { + final View child = mNonDrawerViews.get(i); + if (child.getVisibility() == View.VISIBLE) child.addFocusables(views, direction, focusableMode); + } + } + + mNonDrawerViews.clear(); + } + } + + private boolean hasVisibleDrawer() { + return findVisibleDrawer() != null; + } + + @Nullable + final View findVisibleDrawer() { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (isDrawerView(child) && isDrawerVisible(child)) return child; + } + return null; + } + + @Override + public boolean onKeyDown(final int keyCode, final KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK && hasVisibleDrawer()) { + event.startTracking(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(final int keyCode, final KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + final View visibleDrawer = findVisibleDrawer(); + if (visibleDrawer != null && isDrawerView(visibleDrawer)) closeDrawers(); + return visibleDrawer != null; + } + return super.onKeyUp(keyCode, event); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + if (state instanceof SavedState) { + final SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + + if (ss.openDrawerGravity != Gravity.NO_GRAVITY) { + final View toOpen = findDrawerWithGravity(ss.openDrawerGravity); + if (toOpen != null) openDrawer(toOpen); + } + } else super.onRestoreInstanceState(state); + } + + @Override + protected Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + assert superState != null; + final SavedState ss = new SavedState(superState); + + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + // Is the current child fully opened (that is, not closing)? + final boolean isOpenedAndNotClosing = (lp.openState == LayoutParams.FLAG_IS_OPENED); + // Is the current child opening? + final boolean isClosedAndOpening = (lp.openState == LayoutParams.FLAG_IS_OPENING); + if (isOpenedAndNotClosing || isClosedAndOpening) { + // If one of the conditions above holds, save the child's gravity so that we open that child during state restore. + ss.openDrawerGravity = lp.gravity; + break; + } + } + + return ss; + } + + @Override + public void addView(final View child, final int index, final ViewGroup.LayoutParams params) { + super.addView(child, index, params); + final View openDrawer = findOpenDrawer(); + if (openDrawer == null) isDrawerView(child); + } + + protected static class SavedState extends AbsSavedState { + public static final Creator CREATOR = new ClassLoaderCreator() { + @NonNull + @Override + public SavedState createFromParcel(final Parcel in, final ClassLoader loader) { + return new SavedState(in, loader); + } + + @NonNull + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in, null); + } + + @NonNull + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + int openDrawerGravity = Gravity.NO_GRAVITY; + + public SavedState(@NonNull final Parcelable superState) { + super(superState); + } + + public SavedState(@NonNull final Parcel in, @Nullable final ClassLoader loader) { + super(in, loader); + openDrawerGravity = in.readInt(); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(openDrawerGravity); + } + } + + private class ViewDragCallback extends ViewDragHelper.Callback { + private final int mAbsGravity; + private ViewDragHelper mDragger; + + ViewDragCallback(final int gravity) { + mAbsGravity = gravity; + } + + public void setDragger(final ViewDragHelper dragger) { + mDragger = dragger; + } + + @Override + public boolean tryCaptureView(@NonNull final View child, final int pointerId) { + return isDrawerView(child) && checkDrawerViewAbsoluteGravity(child, mAbsGravity); + } + + @Override + public void onViewDragStateChanged(final int state) { + updateDrawerState(state, mDragger.getCapturedView()); + } + + @Override + public void onViewPositionChanged(@NonNull final View changedView, final int left, final int top, final int dx, final int dy) { + final float offset; + final int childWidth = changedView.getWidth(); + + if (checkDrawerViewAbsoluteGravity(changedView, Gravity.LEFT)) offset = (float) (childWidth + left) / childWidth; + else offset = (float) (getWidth() - left) / childWidth; + + setDrawerViewOffset(changedView, offset); + changedView.setVisibility(offset == 0 ? INVISIBLE : VISIBLE); + invalidate(); + } + + @Override + public void onViewCaptured(@NonNull final View capturedChild, final int activePointerId) { + final LayoutParams lp = (LayoutParams) capturedChild.getLayoutParams(); + lp.isPeeking = false; + closeOtherDrawer(); + } + + private void closeOtherDrawer() { + final int otherGrav = mAbsGravity == Gravity.LEFT ? Gravity.RIGHT : Gravity.LEFT; + final View toClose = findDrawerWithGravity(otherGrav); + if (toClose != null) closeDrawer(toClose); + } + + @Override + public void onViewReleased(@NonNull final View releasedChild, final float xvel, final float yvel) { + final float offset = getDrawerViewOffset(releasedChild); + final int childWidth = releasedChild.getWidth(); + + final int left; + if (checkDrawerViewAbsoluteGravity(releasedChild, Gravity.LEFT)) + left = xvel > 0 || (xvel == 0 && offset > 0.5f) ? 0 : -childWidth; + else { + final int width = getWidth(); + left = xvel < 0 || (xvel == 0 && offset > 0.5f) ? width - childWidth : width; + } + + mDragger.settleCapturedViewAt(left, releasedChild.getTop()); + invalidate(); + } + + @Override + public void onEdgeDragStarted(final int edgeFlags, final int pointerId) { + final View toCapture; + if ((edgeFlags & ViewDragHelper.EDGE_LEFT) == ViewDragHelper.EDGE_LEFT) + toCapture = findDrawerWithGravity(Gravity.LEFT); + else toCapture = findDrawerWithGravity(Gravity.RIGHT); + + if (toCapture != null && isDrawerView(toCapture)) mDragger.captureChildView(toCapture, pointerId); + } + + @Override + public int getViewHorizontalDragRange(@NonNull final View child) { + return isDrawerView(child) ? child.getWidth() : 0; + } + + @Override + public int clampViewPositionHorizontal(@NonNull final View child, final int left, final int dx) { + if (checkDrawerViewAbsoluteGravity(child, Gravity.LEFT)) return Math.max(-child.getWidth(), Math.min(left, 0)); + final int width = getWidth(); + return Math.max(width - child.getWidth(), Math.min(left, width)); + } + + @Override + public int clampViewPositionVertical(@NonNull final View child, final int top, final int dy) { + return child.getTop(); + } + } + + public static class LayoutParams extends ViewGroup.MarginLayoutParams { + private static final int FLAG_IS_CLOSING = 0x4; + public static final int FLAG_IS_OPENED = 0x1; + public static final int FLAG_IS_OPENING = 0x2; + public int openState; + @EdgeGravity + public int gravity = Gravity.NO_GRAVITY; + public boolean isPeeking; + public float onScreen; + + public LayoutParams(@NonNull final Context c, @Nullable final AttributeSet attrs) { + super(c, attrs); + final TypedArray a = c.obtainStyledAttributes(attrs, new int[]{android.R.attr.layout_gravity}); + try { + this.gravity = a.getInt(0, Gravity.NO_GRAVITY); + } finally { + a.recycle(); + } + } + + public LayoutParams(final int width, final int height) { + super(width, height); + } + + public LayoutParams(@NonNull final LayoutParams source) { + super(source); + this.gravity = source.gravity; + } + + public LayoutParams(@NonNull final ViewGroup.LayoutParams source) { + super(source); + } + + public LayoutParams(@NonNull final ViewGroup.MarginLayoutParams source) { + super(source); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/PostsRecyclerView.java b/app/src/main/java/awais/instagrabber/customviews/PostsRecyclerView.java new file mode 100644 index 0000000..3bb0fcd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/PostsRecyclerView.java @@ -0,0 +1,346 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.ViewModelStoreOwner; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import androidx.transition.ChangeBounds; +import androidx.transition.Transition; +import androidx.transition.TransitionManager; +import androidx.work.Data; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.MediaViewModel; +import awais.instagrabber.workers.DownloadWorker; + +public class PostsRecyclerView extends RecyclerView { + private static final String TAG = "PostsRecyclerView"; + + private StaggeredGridLayoutManager layoutManager; + private PostsLayoutPreferences layoutPreferences; + private PostFetcher.PostFetchService postFetchService; + private Transition transition; + private ViewModelStoreOwner viewModelStoreOwner; + private FeedAdapterV2 feedAdapter; + private LifecycleOwner lifeCycleOwner; + private MediaViewModel mediaViewModel; + private boolean initCalled = false; + private GridSpacingItemDecoration gridSpacingItemDecoration; + private RecyclerLazyLoaderAtEdge lazyLoader; + private FeedAdapterV2.FeedItemCallback feedItemCallback; + private boolean shouldScrollToTop; + private FeedAdapterV2.SelectionModeCallback selectionModeCallback; + + private final List fetchStatusChangeListeners = new ArrayList<>(); + + private final RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) { + @Override + protected int getVerticalSnapPreference() { + return LinearSmoothScroller.SNAP_TO_START; + } + }; + + public PostsRecyclerView(@NonNull final Context context) { + super(context); + } + + public PostsRecyclerView(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public PostsRecyclerView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public PostsRecyclerView setViewModelStoreOwner(final ViewModelStoreOwner owner) { + if (initCalled) { + throw new IllegalArgumentException("init already called!"); + } + this.viewModelStoreOwner = owner; + return this; + } + + public PostsRecyclerView setLifeCycleOwner(final LifecycleOwner lifeCycleOwner) { + if (initCalled) { + throw new IllegalArgumentException("init already called!"); + } + this.lifeCycleOwner = lifeCycleOwner; + return this; + } + + public PostsRecyclerView setPostFetchService(final PostFetcher.PostFetchService postFetchService) { + if (initCalled) { + throw new IllegalArgumentException("init already called!"); + } + this.postFetchService = postFetchService; + return this; + } + + public PostsRecyclerView setFeedItemCallback(@NonNull final FeedAdapterV2.FeedItemCallback feedItemCallback) { + this.feedItemCallback = feedItemCallback; + return this; + } + + public PostsRecyclerView setSelectionModeCallback(@NonNull final FeedAdapterV2.SelectionModeCallback selectionModeCallback) { + this.selectionModeCallback = selectionModeCallback; + return this; + } + + public PostsRecyclerView setLayoutPreferences(final PostsLayoutPreferences layoutPreferences) { + this.layoutPreferences = layoutPreferences; + if (initCalled) { + if (layoutPreferences == null) return this; + feedAdapter.setLayoutPreferences(layoutPreferences); + updateLayout(); + } + return this; + } + + public void init() { + initCalled = true; + if (viewModelStoreOwner == null) { + throw new IllegalArgumentException("ViewModelStoreOwner cannot be null"); + } else if (lifeCycleOwner == null) { + throw new IllegalArgumentException("LifecycleOwner cannot be null"); + } else if (postFetchService == null) { + throw new IllegalArgumentException("PostFetchService cannot be null"); + } + if (layoutPreferences == null) { + layoutPreferences = PostsLayoutPreferences.builder().build(); + // Utils.settingsHelper.putString(Constants.PREF_POSTS_LAYOUT, layoutPreferences.getJson()); + } + gridSpacingItemDecoration = new GridSpacingItemDecoration(Utils.convertDpToPx(2)); + initTransition(); + initAdapter(); + initLayoutManager(); + initSelf(); + initDownloadWorkerListener(); + } + + private void initTransition() { + transition = new ChangeBounds(); + transition.setDuration(300); + } + + private void initLayoutManager() { + layoutManager = new StaggeredGridLayoutManager(layoutPreferences.getColCount(), StaggeredGridLayoutManager.VERTICAL); + setLayoutManager(layoutManager); + } + + private void initAdapter() { + feedAdapter = new FeedAdapterV2(layoutPreferences, feedItemCallback, selectionModeCallback); + feedAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); + setAdapter(feedAdapter); + } + + private void initSelf() { + try { + mediaViewModel = new ViewModelProvider( + viewModelStoreOwner, + new MediaViewModel.ViewModelFactory(postFetchService) + ).get(MediaViewModel.class); + } catch (Exception e) { + Log.e(TAG, "initSelf: ", e); + } + if (mediaViewModel == null) return; + final LiveData> mediaListLiveData = mediaViewModel.getList(); + mediaListLiveData.observe(lifeCycleOwner, list -> feedAdapter.submitList(list, () -> { + dispatchFetchStatus(); + postDelayed(this::fetchMoreIfPossible, 1000); + if (!shouldScrollToTop) return; + shouldScrollToTop = false; + post(() -> smoothScrollToPosition(0)); + })); + if (layoutPreferences.getHasGap()) { + addItemDecoration(gridSpacingItemDecoration); + } + setHasFixedSize(true); + setNestedScrollingEnabled(true); + setItemAnimator(null); + lazyLoader = new RecyclerLazyLoaderAtEdge(layoutManager, (page) -> { + if (mediaViewModel.hasMore()) { + mediaViewModel.fetch(); + dispatchFetchStatus(); + } + }); + addOnScrollListener(lazyLoader); + if (mediaListLiveData.getValue() == null || mediaListLiveData.getValue().isEmpty()) { + mediaViewModel.fetch(); + dispatchFetchStatus(); + } + } + + private void fetchMoreIfPossible() { + if (!mediaViewModel.hasMore()) return; + if (feedAdapter.getItemCount() == 0) return; + final LayoutManager layoutManager = getLayoutManager(); + if (!(layoutManager instanceof StaggeredGridLayoutManager)) return; + final int[] itemPositions = ((StaggeredGridLayoutManager) layoutManager).findLastCompletelyVisibleItemPositions(null); + final boolean allNoPosition = Arrays.stream(itemPositions).allMatch(position -> position == RecyclerView.NO_POSITION); + if (allNoPosition) return; + final boolean match = Arrays.stream(itemPositions).anyMatch(position -> position == feedAdapter.getItemCount() - 1); + if (!match) return; + mediaViewModel.fetch(); + dispatchFetchStatus(); + } + + private void initDownloadWorkerListener() { + WorkManager.getInstance(getContext()) + .getWorkInfosByTagLiveData("download") + .observe(lifeCycleOwner, workInfoList -> { + for (final WorkInfo workInfo : workInfoList) { + if (workInfo == null) continue; + final Data progress = workInfo.getProgress(); + final float progressPercent = progress.getFloat(DownloadWorker.PROGRESS, 0); + if (progressPercent != 100) continue; + final String url = progress.getString(DownloadWorker.URL); + final List feedModels = mediaViewModel.getList().getValue(); + if (feedModels == null) continue; + for (int i = 0; i < feedModels.size(); i++) { + final Media feedModel = feedModels.get(i); + final List displayUrls = getDisplayUrl(feedModel); + if (displayUrls.contains(url)) { + feedAdapter.notifyItemChanged(i); + break; + } + } + } + }); + } + + private List getDisplayUrl(final Media feedModel) { + List urls = Collections.emptyList(); + if (feedModel == null || feedModel.getType() == null) return urls; + switch (feedModel.getType()) { + case MEDIA_TYPE_IMAGE: + case MEDIA_TYPE_VIDEO: + urls = Collections.singletonList(ResponseBodyUtils.getImageUrl(feedModel)); + break; + case MEDIA_TYPE_SLIDER: + final List sliderItems = feedModel.getCarouselMedia(); + if (sliderItems != null) { + final ImmutableList.Builder builder = ImmutableList.builder(); + for (final Media child : sliderItems) { + builder.add(ResponseBodyUtils.getImageUrl(child)); + } + urls = builder.build(); + } + break; + default: + } + return urls; + } + + private void updateLayout() { + post(() -> { + TransitionManager.beginDelayedTransition(this, transition); + feedAdapter.notifyDataSetChanged(); + final int itemDecorationCount = getItemDecorationCount(); + if (!layoutPreferences.getHasGap()) { + if (itemDecorationCount == 1) { + removeItemDecoration(gridSpacingItemDecoration); + } + } else { + if (itemDecorationCount == 0) { + addItemDecoration(gridSpacingItemDecoration); + } + } + if (layoutPreferences.getType() == PostsLayoutPreferences.PostsLayoutType.LINEAR) { + if (layoutManager.getSpanCount() != 1) { + layoutManager.setSpanCount(1); + setAdapter(null); + setAdapter(feedAdapter); + } + } else { + boolean shouldRedraw = layoutManager.getSpanCount() == 1; + layoutManager.setSpanCount(layoutPreferences.getColCount()); + if (shouldRedraw) { + setAdapter(null); + setAdapter(feedAdapter); + } + } + }); + } + + public void refresh() { + shouldScrollToTop = true; + if (lazyLoader != null) { + lazyLoader.resetState(); + } + if (mediaViewModel != null) { + mediaViewModel.refresh(); + } + dispatchFetchStatus(); + } + + public boolean isFetching() { + return mediaViewModel != null && mediaViewModel.isFetching(); + } + + public PostsRecyclerView addFetchStatusChangeListener(final FetchStatusChangeListener fetchStatusChangeListener) { + if (fetchStatusChangeListener == null) return this; + fetchStatusChangeListeners.add(fetchStatusChangeListener); + return this; + } + + public void removeFetchStatusListener(final FetchStatusChangeListener fetchStatusChangeListener) { + if (fetchStatusChangeListener == null) return; + fetchStatusChangeListeners.remove(fetchStatusChangeListener); + } + + private void dispatchFetchStatus() { + for (final FetchStatusChangeListener listener : fetchStatusChangeListeners) { + listener.onFetchStatusChange(isFetching()); + } + } + + public PostsLayoutPreferences getLayoutPreferences() { + return layoutPreferences; + } + + public void endSelection() { + feedAdapter.endSelection(); + } + + public interface FetchStatusChangeListener { + void onFetchStatusChange(boolean fetching); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + lifeCycleOwner = null; + initCalled = false; + } + + @Override + public void smoothScrollToPosition(final int position) { + smoothScroller.setTargetPosition(position); + layoutManager.startSmoothScroll(smoothScroller); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/PrimaryActionModeCallback.java b/app/src/main/java/awais/instagrabber/customviews/PrimaryActionModeCallback.java new file mode 100644 index 0000000..a66ab4a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/PrimaryActionModeCallback.java @@ -0,0 +1,71 @@ +package awais.instagrabber.customviews; + +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; + +public class PrimaryActionModeCallback implements ActionMode.Callback { + private ActionMode mode; + private int menuRes; + private final Callbacks callbacks; + + public PrimaryActionModeCallback(final int menuRes, final Callbacks callbacks) { + this.menuRes = menuRes; + this.callbacks = callbacks; + } + + @Override + public boolean onCreateActionMode(final ActionMode mode, final Menu menu) { + this.mode = mode; + mode.getMenuInflater().inflate(menuRes, menu); + if (callbacks != null) { + callbacks.onCreate(mode, menu); + } + return true; + } + + @Override + public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + if (callbacks != null) { + return callbacks.onActionItemClicked(mode, item); + } + return false; + } + + @Override + public void onDestroyActionMode(final ActionMode mode) { + if (callbacks != null) { + callbacks.onDestroy(mode); + } + this.mode = null; + } + + public abstract static class CallbacksHelper implements Callbacks { + public void onCreate(final ActionMode mode, final Menu menu) { + + } + + @Override + public void onDestroy(final ActionMode mode) { + + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + return false; + } + } + + public interface Callbacks { + void onCreate(final ActionMode mode, final Menu menu); + + void onDestroy(final ActionMode mode); + + boolean onActionItemClicked(final ActionMode mode, final MenuItem item); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/ProfilePicView.java b/app/src/main/java/awais/instagrabber/customviews/ProfilePicView.java new file mode 100644 index 0000000..491127a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/ProfilePicView.java @@ -0,0 +1,151 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.ViewGroup; + +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; + +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.RoundingParams; + +import java.util.HashMap; +import java.util.Map; + +import awais.instagrabber.R; + +public final class ProfilePicView extends CircularImageView { + private static final String TAG = "ProfilePicView"; + + private Size size; + private int dimensionPixelSize; + + public ProfilePicView(Context context, GenericDraweeHierarchy hierarchy) { + super(context); + setHierarchy(hierarchy); + size = Size.REGULAR; + updateLayout(); + } + + public ProfilePicView(final Context context) { + super(context); + size = Size.REGULAR; + updateLayout(); + } + + public ProfilePicView(final Context context, final AttributeSet attrs) { + super(context, attrs); + parseAttrs(context, attrs); + updateLayout(); + } + + public ProfilePicView(final Context context, + final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + parseAttrs(context, attrs); + updateLayout(); + } + + private void parseAttrs(final Context context, final AttributeSet attrs) { + final TypedArray a = context.getTheme().obtainStyledAttributes( + attrs, + R.styleable.ProfilePicView, + 0, + 0); + try { + final int sizeValue = a.getInt(R.styleable.ProfilePicView_size, Size.REGULAR.getValue()); + size = Size.valueOf(sizeValue); + } finally { + a.recycle(); + } + } + + private void updateLayout() { + @DimenRes final int dimenRes; + switch (size) { + case SMALL: + dimenRes = R.dimen.profile_pic_size_small; + break; + case SMALLER: + dimenRes = R.dimen.profile_pic_size_smaller; + break; + case TINY: + dimenRes = R.dimen.profile_pic_size_tiny; + break; + case LARGE: + dimenRes = R.dimen.profile_pic_size_large; + break; + default: + case REGULAR: + dimenRes = R.dimen.profile_pic_size_regular; + break; + } + ViewGroup.LayoutParams layoutParams = getLayoutParams(); + if (layoutParams == null) { + layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + dimensionPixelSize = getResources().getDimensionPixelSize(dimenRes); + layoutParams.width = dimensionPixelSize; + layoutParams.height = dimensionPixelSize; + + // invalidate(); + // requestLayout(); + } + + public void setSize(final Size size) { + this.size = size; + updateLayout(); + } + + public void setStoriesBorder() { + // private final int borderSize = 8; + final int color = Color.GREEN; + RoundingParams roundingParams = getHierarchy().getRoundingParams(); + if (roundingParams == null) { + roundingParams = RoundingParams.asCircle().setRoundingMethod(RoundingParams.RoundingMethod.BITMAP_ONLY); + } + roundingParams.setBorder(color, 5.0f); + getHierarchy().setRoundingParams(roundingParams); + } + + public enum Size { + TINY(0), + SMALL(1), + REGULAR(2), + LARGE(3), + SMALLER(4); + + private final int value; + private static final Map map = new HashMap<>(); + + static { + for (Size size : Size.values()) { + map.put(size.value, size); + } + } + + Size(final int value) { + this.value = value; + } + + @NonNull + public static Size valueOf(final int value) { + final Size size = map.get(value); + return size != null ? size : Size.REGULAR; + } + + public int getValue() { + return value; + } + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + setMeasuredDimension(dimensionPixelSize, dimensionPixelSize); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java b/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java new file mode 100644 index 0000000..3fc467e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java @@ -0,0 +1,181 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.text.InputFilter; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.emoji.widget.EmojiTextViewHelper; + +import java.util.ArrayList; +import java.util.List; + +import io.github.armcha.autolink.AutoLinkItem; +import io.github.armcha.autolink.AutoLinkTextView; +import io.github.armcha.autolink.MODE_EMAIL; +import io.github.armcha.autolink.MODE_HASHTAG; +import io.github.armcha.autolink.MODE_MENTION; +import io.github.armcha.autolink.MODE_URL; +import io.github.armcha.autolink.Mode; + +public class RamboTextViewV2 extends AutoLinkTextView { + private final List onMentionClickListeners = new ArrayList<>(); + private final List onHashtagClickListeners = new ArrayList<>(); + private final List onURLClickListeners = new ArrayList<>(); + private final List onEmailClickListeners = new ArrayList<>(); + + private EmojiTextViewHelper emojiTextViewHelper; + + public RamboTextViewV2(@NonNull final Context context, + @Nullable final AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + getEmojiTextViewHelper().updateTransformationMethod(); + addAutoLinkMode(MODE_HASHTAG.INSTANCE, MODE_MENTION.INSTANCE, MODE_EMAIL.INSTANCE, MODE_URL.INSTANCE); + onAutoLinkClick(autoLinkItem -> { + final Mode mode = autoLinkItem.getMode(); + if (mode.equals(MODE_MENTION.INSTANCE)) { + for (final OnMentionClickListener onMentionClickListener : onMentionClickListeners) { + onMentionClickListener.onMentionClick(autoLinkItem); + } + return; + } + if (mode.equals(MODE_HASHTAG.INSTANCE)) { + for (final OnHashtagClickListener onHashtagClickListener : onHashtagClickListeners) { + onHashtagClickListener.onHashtagClick(autoLinkItem); + } + return; + } + if (mode.equals(MODE_URL.INSTANCE)) { + for (final OnURLClickListener onURLClickListener : onURLClickListeners) { + onURLClickListener.onURLClick(autoLinkItem); + } + return; + } + if (mode.equals(MODE_EMAIL.INSTANCE)) { + for (final OnEmailClickListener onEmailClickListener : onEmailClickListeners) { + onEmailClickListener.onEmailClick(autoLinkItem); + } + } + }); + onAutoLinkLongClick(autoLinkItem -> {}); + } + + @Override + public void setFilters(InputFilter[] filters) { + super.setFilters(getEmojiTextViewHelper().getFilters(filters)); + } + + @Override + public void setAllCaps(boolean allCaps) { + super.setAllCaps(allCaps); + getEmojiTextViewHelper().setAllCaps(allCaps); + } + + + private EmojiTextViewHelper getEmojiTextViewHelper() { + if (emojiTextViewHelper == null) { + emojiTextViewHelper = new EmojiTextViewHelper(this); + } + return emojiTextViewHelper; + } + + public void addOnMentionClickListener(final OnMentionClickListener onMentionClickListener) { + if (onMentionClickListener == null) { + return; + } + onMentionClickListeners.add(onMentionClickListener); + } + + public void removeOnMentionClickListener(final OnMentionClickListener onMentionClickListener) { + if (onMentionClickListener == null) { + return; + } + onMentionClickListeners.remove(onMentionClickListener); + } + + public void clearOnMentionClickListeners() { + onMentionClickListeners.clear(); + } + + public void addOnHashtagListener(final OnHashtagClickListener onHashtagClickListener) { + if (onHashtagClickListener == null) { + return; + } + onHashtagClickListeners.add(onHashtagClickListener); + } + + public void removeOnHashtagListener(final OnHashtagClickListener onHashtagClickListener) { + if (onHashtagClickListener == null) { + return; + } + onHashtagClickListeners.remove(onHashtagClickListener); + } + + public void clearOnHashtagClickListeners() { + onHashtagClickListeners.clear(); + } + + public void addOnURLClickListener(final OnURLClickListener onURLClickListener) { + if (onURLClickListener == null) { + return; + } + onURLClickListeners.add(onURLClickListener); + } + + public void removeOnURLClickListener(final OnURLClickListener onURLClickListener) { + if (onURLClickListener == null) { + return; + } + onURLClickListeners.remove(onURLClickListener); + } + + public void clearOnURLClickListeners() { + onURLClickListeners.clear(); + } + + public void addOnEmailClickListener(final OnEmailClickListener onEmailClickListener) { + if (onEmailClickListener == null) { + return; + } + onEmailClickListeners.add(onEmailClickListener); + } + + public void removeOnEmailClickListener(final OnEmailClickListener onEmailClickListener) { + if (onEmailClickListener == null) { + return; + } + onEmailClickListeners.remove(onEmailClickListener); + } + + public void clearOnEmailClickListeners() { + onEmailClickListeners.clear(); + } + + public void clearAllAutoLinkListeners() { + clearOnMentionClickListeners(); + clearOnHashtagClickListeners(); + clearOnURLClickListeners(); + clearOnEmailClickListeners(); + } + + public interface OnMentionClickListener { + void onMentionClick(final AutoLinkItem autoLinkItem); + } + + public interface OnHashtagClickListener { + void onHashtagClick(final AutoLinkItem autoLinkItem); + } + + public interface OnURLClickListener { + void onURLClick(final AutoLinkItem autoLinkItem); + } + + public interface OnEmailClickListener { + void onEmailClick(final AutoLinkItem autoLinkItem); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/ReactionEmojiTextView.java b/app/src/main/java/awais/instagrabber/customviews/ReactionEmojiTextView.java new file mode 100644 index 0000000..2e4e7a7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/ReactionEmojiTextView.java @@ -0,0 +1,82 @@ +package awais.instagrabber.customviews; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.emoji.widget.EmojiAppCompatTextView; + +import java.util.List; +import java.util.stream.Collectors; + +public class ReactionEmojiTextView extends EmojiAppCompatTextView { + private static final String TAG = ReactionEmojiTextView.class.getSimpleName(); + + private final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); + + private String count = ""; + private SpannableString ellipsisSpannable; + private String distinctEmojis; + + public ReactionEmojiTextView(final Context context) { + super(context); + init(); + } + + public ReactionEmojiTextView(final Context context, final AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ReactionEmojiTextView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + ellipsisSpannable = new SpannableString(count); + } + + @SuppressLint("SetTextI18n") + public void setEmojis(@NonNull final List emojis) { + count = String.valueOf(emojis.size()); + distinctEmojis = emojis.stream() + .distinct() + .collect(Collectors.joining()); + ellipsisSpannable = new SpannableString(count); + setText(distinctEmojis + (emojis.size() > 1 ? count : "")); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + final CharSequence text = getText(); + if (text == null) return; + final int measuredWidth = getMeasuredWidth(); + float availableTextWidth = measuredWidth - getCompoundPaddingLeft() - getCompoundPaddingRight(); + CharSequence ellipsizedText = TextUtils.ellipsize(text, getPaint(), availableTextWidth, getEllipsize()); + if (!ellipsizedText.toString().equals(text.toString())) { + // If the ellipsizedText is different than the original text, this means that it didn't fit and got indeed ellipsized. + // Calculate the new availableTextWidth by taking into consideration the size of the custom ellipsis, too. + availableTextWidth = (availableTextWidth - getPaint().measureText(count)); + ellipsizedText = TextUtils.ellipsize(text, getPaint(), availableTextWidth, getEllipsize()); + final int defaultEllipsisStart = ellipsizedText.toString().indexOf(getDefaultEllipsis()); + final int defaultEllipsisEnd = defaultEllipsisStart + 1; + spannableStringBuilder.clear(); + // Update the text with the ellipsized version and replace the default ellipsis with the custom one. + final SpannableStringBuilder replace = spannableStringBuilder.append(ellipsizedText) + .replace(defaultEllipsisStart, defaultEllipsisEnd, ellipsisSpannable); + setText(replace); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + private char getDefaultEllipsis() { + return '…'; + } + +} diff --git a/app/src/main/java/awais/instagrabber/customviews/RecordButton.java b/app/src/main/java/awais/instagrabber/customviews/RecordButton.java new file mode 100644 index 0000000..91877f5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/RecordButton.java @@ -0,0 +1,116 @@ +package awais.instagrabber.customviews; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import com.google.android.material.button.MaterialButton; + +import awais.instagrabber.animations.ScaleAnimation; + +/** + * Created by Devlomi on 13/12/2017. + */ + +public class RecordButton extends MaterialButton implements View.OnTouchListener, View.OnClickListener, View.OnLongClickListener { + + private ScaleAnimation scaleAnimation; + private RecordView recordView; + private boolean listenForRecord = true; + private OnRecordClickListener onRecordClickListener; + private OnRecordLongClickListener onRecordLongClickListener; + + public RecordButton(Context context) { + super(context); + init(context, null); + } + + public RecordButton(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public RecordButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + @SuppressLint("ClickableViewAccessibility") + private void init(Context context, AttributeSet attrs) { + scaleAnimation = new ScaleAnimation(this); + this.setOnTouchListener(this); + this.setOnClickListener(this); + this.setOnLongClickListener(this); + } + + public void setRecordView(RecordView recordView) { + this.recordView = recordView; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (isListenForRecord()) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + recordView.onActionDown((RecordButton) v, event); + break; + case MotionEvent.ACTION_MOVE: + recordView.onActionMove((RecordButton) v, event, false); + break; + case MotionEvent.ACTION_UP: + recordView.onActionUp((RecordButton) v); + break; + } + } + return isListenForRecord(); + } + + protected void startScale() { + scaleAnimation.start(); + } + + public void stopScale() { + scaleAnimation.stop(); + } + + public void setListenForRecord(boolean listenForRecord) { + this.listenForRecord = listenForRecord; + } + + public boolean isListenForRecord() { + return listenForRecord; + } + + public void setOnRecordClickListener(OnRecordClickListener onRecordClickListener) { + this.onRecordClickListener = onRecordClickListener; + } + + public void setOnRecordLongClickListener(OnRecordLongClickListener onRecordLongClickListener) { + this.onRecordLongClickListener = onRecordLongClickListener; + } + + @Override + public void onClick(View v) { + if (onRecordClickListener != null) { + onRecordClickListener.onClick(v); + } + } + + @Override + public boolean onLongClick(final View v) { + if (onRecordLongClickListener != null) { + return onRecordLongClickListener.onLongClick(v); + } + return false; + } + + public interface OnRecordClickListener { + void onClick(View v); + } + + public interface OnRecordLongClickListener { + boolean onLongClick(View v); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/RecordView.java b/app/src/main/java/awais/instagrabber/customviews/RecordView.java new file mode 100644 index 0000000..f04cd64 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/RecordView.java @@ -0,0 +1,352 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.media.MediaPlayer; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.widget.RelativeLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.graphics.drawable.DrawableCompat; + +import java.io.IOException; + +import awais.instagrabber.R; +import awais.instagrabber.customviews.helpers.RecordViewAnimationHelper; +import awais.instagrabber.databinding.RecordViewLayoutBinding; +import awais.instagrabber.utils.Utils; + +/** + * Created by Devlomi on 24/08/2017. + */ + +public class RecordView extends RelativeLayout { + private static final String TAG = RecordView.class.getSimpleName(); + + public static final int DEFAULT_CANCEL_BOUNDS = 8; //8dp + // private ImageView smallBlinkingMic; + // private ImageView basketImg; + // private Chronometer counterTime; + // private TextView slideToCancel; + // private LinearLayout slideToCancelLayout; + private float initialX; + private float basketInitialY; + private float difX = 0; + private float cancelBounds = DEFAULT_CANCEL_BOUNDS; + private long startTime; + private final Context context; + private OnRecordListener onRecordListener; + private boolean isSwiped; + private boolean isLessThanMinAllowed = false; + private boolean isSoundEnabled = true; + private int RECORD_START = R.raw.record_start; + private int RECORD_FINISHED = R.raw.record_finished; + private int RECORD_ERROR = R.raw.record_error; + private RecordViewAnimationHelper recordViewAnimationHelper; + private RecordViewLayoutBinding binding; + private int minMillis = 1000; + + + public RecordView(Context context) { + super(context); + this.context = context; + init(context, null, -1, -1); + } + + + public RecordView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + this.context = context; + init(context, attrs, -1, -1); + } + + public RecordView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + this.context = context; + init(context, attrs, defStyleAttr, -1); + } + + private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + binding = RecordViewLayoutBinding.inflate(LayoutInflater.from(context), this, false); + addView(binding.getRoot()); + hideViews(true); + if (attrs != null && defStyleAttr == -1 && defStyleRes == -1) { + TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.RecordView, defStyleAttr, defStyleRes); + int slideArrowResource = typedArray.getResourceId(R.styleable.RecordView_slide_to_cancel_arrow, -1); + String slideToCancelText = typedArray.getString(R.styleable.RecordView_slide_to_cancel_text); + int slideToCancelTextColor = typedArray.getResourceId(R.styleable.RecordView_slide_to_cancel_text_color, -1); + int slideMarginRight = (int) typedArray.getDimension(R.styleable.RecordView_slide_to_cancel_margin_right, 30); + int counterTimeColor = typedArray.getResourceId(R.styleable.RecordView_counter_time_color, -1); + int arrowColor = typedArray.getResourceId(R.styleable.RecordView_slide_to_cancel_arrow_color, -1); + int cancelBounds = typedArray.getDimensionPixelSize(R.styleable.RecordView_slide_to_cancel_bounds, -1); + if (cancelBounds != -1) { + setCancelBounds(cancelBounds, false);//don't convert it to pixels since it's already in pixels + } + if (slideToCancelText != null) { + setSlideToCancelText(slideToCancelText); + } + if (slideToCancelTextColor != -1) { + setSlideToCancelTextColor(getResources().getColor(slideToCancelTextColor)); + } + if (slideArrowResource != -1) { + setSlideArrowDrawable(slideArrowResource); + } + if (arrowColor != -1) { + setSlideToCancelArrowColor(getResources().getColor(arrowColor)); + } + if (counterTimeColor != -1) { + setCounterTimeColor(getResources().getColor(counterTimeColor)); + } + setMarginRight(slideMarginRight, true); + typedArray.recycle(); + } + recordViewAnimationHelper = new RecordViewAnimationHelper(context, binding.basketImg, binding.glowingMic); + } + + private void hideViews(boolean hideSmallMic) { + binding.slideToCancel.setVisibility(GONE); + binding.basketImg.setVisibility(GONE); + binding.counterTv.setVisibility(GONE); + if (hideSmallMic) { + binding.glowingMic.setVisibility(GONE); + } + } + + private void showViews() { + binding.slideToCancel.setVisibility(VISIBLE); + binding.glowingMic.setVisibility(VISIBLE); + binding.counterTv.setVisibility(VISIBLE); + } + + private boolean isLessThanMin(long time) { + return time <= minMillis; + } + + private void playSound(int soundRes) { + if (!isSoundEnabled) return; + if (soundRes == 0) return; + try { + final MediaPlayer player = new MediaPlayer(); + AssetFileDescriptor afd = context.getResources().openRawResourceFd(soundRes); + if (afd == null) return; + player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + afd.close(); + player.prepare(); + player.start(); + player.setOnCompletionListener(MediaPlayer::release); + player.setLooping(false); + } catch (IOException e) { + Log.e(TAG, "playSound", e); + } + } + + protected void onActionDown(RecordButton recordBtn, MotionEvent motionEvent) { + if (onRecordListener != null) { + onRecordListener.onStart(); + } + recordViewAnimationHelper.setStartRecorded(true); + recordViewAnimationHelper.resetBasketAnimation(); + recordViewAnimationHelper.resetSmallMic(); + recordBtn.startScale(); + // slideToCancelLayout.startShimmerAnimation(); + + initialX = recordBtn.getX(); + basketInitialY = binding.basketImg.getY() + 90; + // playSound(RECORD_START); + showViews(); + + recordViewAnimationHelper.animateSmallMicAlpha(); + binding.counterTv.setBase(SystemClock.elapsedRealtime()); + startTime = System.currentTimeMillis(); + binding.counterTv.start(); + isSwiped = false; + } + + protected void onActionMove(RecordButton recordBtn, MotionEvent motionEvent, final boolean forceCancel) { + long time = System.currentTimeMillis() - startTime; + if (isSwiped) return; + //Swipe To Cancel + if (forceCancel || (binding.slideToCancel.getX() != 0 && binding.slideToCancel.getX() <= binding.counterTv.getRight() + cancelBounds)) { + //if the time was less than one second then do not start basket animation + if (isLessThanMin(time)) { + hideViews(true); + recordViewAnimationHelper.clearAlphaAnimation(false); + if (onRecordListener != null) { + onRecordListener.onLessThanMin(); + } + recordViewAnimationHelper.onAnimationEnd(); + } else { + hideViews(false); + recordViewAnimationHelper.animateBasket(basketInitialY); + } + recordViewAnimationHelper.moveRecordButtonAndSlideToCancelBack(recordBtn, binding.slideToCancel, initialX, difX); + binding.counterTv.stop(); + // slideToCancelLayout.stopShimmerAnimation(); + isSwiped = true; + recordViewAnimationHelper.setStartRecorded(false); + if (onRecordListener != null) { + onRecordListener.onCancel(); + } + return; + } + //if statement is to Prevent Swiping out of bounds + if (!(motionEvent.getRawX() < initialX)) return; + recordBtn.animate() + .x(motionEvent.getRawX()) + .setDuration(0) + .start(); + if (difX == 0) { + difX = (initialX - binding.slideToCancel.getX()); + } + binding.slideToCancel.animate() + .x(motionEvent.getRawX() - difX) + .setDuration(0) + .start(); + } + + protected void onActionUp(RecordButton recordBtn) { + final long elapsedTime = System.currentTimeMillis() - startTime; + if (!isLessThanMinAllowed && isLessThanMin(elapsedTime) && !isSwiped) { + if (onRecordListener != null) { + onRecordListener.onLessThanMin(); + } + recordViewAnimationHelper.setStartRecorded(false); + // playSound(RECORD_ERROR); + } else { + if (onRecordListener != null && !isSwiped) { + onRecordListener.onFinish(elapsedTime); + } + recordViewAnimationHelper.setStartRecorded(false); + if (!isSwiped) { + // playSound(RECORD_FINISHED); + } + } + //if user has swiped then do not hide SmallMic since it will be hidden after swipe Animation + hideViews(!isSwiped); + if (!isSwiped) { + recordViewAnimationHelper.clearAlphaAnimation(true); + } + recordViewAnimationHelper.moveRecordButtonAndSlideToCancelBack(recordBtn, binding.slideToCancel, initialX, difX); + binding.counterTv.stop(); + // slideToCancelLayout.stopShimmerAnimation(); + } + + private void setMarginRight(int marginRight, boolean convertToDp) { + ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) binding.slideToCancel.getLayoutParams(); + if (convertToDp) { + layoutParams.rightMargin = Utils.convertDpToPx(marginRight); + } else { + layoutParams.rightMargin = marginRight; + } + binding.slideToCancel.setLayoutParams(layoutParams); + } + + private void setSlideArrowDrawable(@DrawableRes final int slideArrowResource) { + Drawable slideArrow = AppCompatResources.getDrawable(getContext(), slideArrowResource); + // Log.d(TAG, "setSlideArrowDrawable: slideArrow: " + slideArrow); + if (slideArrow == null) return; + slideArrow.setBounds(0, 0, slideArrow.getIntrinsicWidth(), slideArrow.getIntrinsicHeight()); + binding.slideToCancel.setCompoundDrawablesRelative(slideArrow, null, null, null); + } + + public void setOnRecordListener(OnRecordListener onRecordListener) { + this.onRecordListener = onRecordListener; + } + + public void setOnBasketAnimationEndListener(OnBasketAnimationEnd onBasketAnimationEndListener) { + recordViewAnimationHelper.setOnBasketAnimationEndListener(onBasketAnimationEndListener); + } + + public void setSoundEnabled(boolean isEnabled) { + isSoundEnabled = isEnabled; + } + + public void setLessThanMinAllowed(boolean isAllowed) { + isLessThanMinAllowed = isAllowed; + } + + public void setSlideToCancelText(String text) { + binding.slideToCancel.setText(text); + } + + public void setSlideToCancelTextColor(int color) { + binding.slideToCancel.setTextColor(color); + } + + public void setSmallMicColor(int color) { + binding.glowingMic.setColorFilter(color); + } + + public void setSmallMicIcon(int icon) { + binding.glowingMic.setImageResource(icon); + } + + public void setSlideMarginRight(int marginRight) { + setMarginRight(marginRight, true); + } + + public void setCustomSounds(int startSound, int finishedSound, int errorSound) { + //0 means do not play sound + RECORD_START = startSound; + RECORD_FINISHED = finishedSound; + RECORD_ERROR = errorSound; + } + + public float getCancelBounds() { + return cancelBounds; + } + + public void setCancelBounds(float cancelBounds) { + setCancelBounds(cancelBounds, true); + } + + //set Chronometer color + public void setCounterTimeColor(@ColorInt int color) { + binding.counterTv.setTextColor(color); + } + + public void setSlideToCancelArrowColor(@ColorInt int color) { + Drawable drawable = binding.slideToCancel.getCompoundDrawablesRelative()[0]; + drawable = DrawableCompat.wrap(drawable); + DrawableCompat.setTint(drawable.mutate(), color); + binding.slideToCancel.setCompoundDrawablesRelative(drawable, null, null, null); + } + + private void setCancelBounds(float cancelBounds, boolean convertDpToPixel) { + this.cancelBounds = convertDpToPixel ? Utils.convertDpToPx(cancelBounds) : cancelBounds; + } + + public void setMinMillis(final int minMillis) { + this.minMillis = minMillis; + } + + public void cancelRecording(final RecordButton recordBtn) { + onActionMove(recordBtn, null, true); + } + + public interface OnBasketAnimationEnd { + void onAnimationEnd(); + } + + public interface OnRecordListener { + void onStart(); + + void onCancel(); + + void onFinish(long recordTime); + + void onLessThanMin(); + } +} + + diff --git a/app/src/main/java/awais/instagrabber/customviews/SharedElementTransitionDialogFragment.java b/app/src/main/java/awais/instagrabber/customviews/SharedElementTransitionDialogFragment.java new file mode 100644 index 0000000..3496aea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/SharedElementTransitionDialogFragment.java @@ -0,0 +1,306 @@ +package awais.instagrabber.customviews; + +import android.animation.Animator; +import android.graphics.Rect; +import android.os.Handler; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.AccelerateDecelerateInterpolator; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.transition.ChangeBounds; +import androidx.transition.ChangeTransform; +import androidx.transition.Transition; +import androidx.transition.TransitionListenerAdapter; +import androidx.transition.TransitionManager; +import androidx.transition.TransitionSet; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import awais.instagrabber.utils.Utils; + +public abstract class SharedElementTransitionDialogFragment extends DialogFragment { + // private static final String TAG = "SETDialogFragment"; + private static final int DURATION = 200; + + private final Map startViews = new HashMap<>(); + private final Map destViews = new HashMap<>(); + private final Map viewBoundsMap = new HashMap<>(); + private final List additionalAnimators = new ArrayList<>(); + private final Handler initialBoundsHandler = new Handler(); + + private boolean startCalled = false; + private boolean startInitiated = false; + private int boundsCalculatedCount = 0; + + protected int getAnimationDuration() { + return DURATION; + } + + public void addSharedElement(@NonNull final View startView, @NonNull final View destView) { + final int key = destView.hashCode(); + startViews.put(key, startView); + destViews.put(key, destView); + setupInitialBounds(startView, destView); + // final View view = getView(); + // if (view == null) return; + // view.post(() -> {}); + } + + public void startPostponedEnterTransition() { + startCalled = true; + if (startInitiated) return; + if (boundsCalculatedCount < startViews.size()) return; + startInitiated = true; + final Set keySet = startViews.keySet(); + final View view = getView(); + if (!(view instanceof ViewGroup)) return; + final TransitionSet transitionSet = new TransitionSet() + .setDuration(DURATION) + .setInterpolator(new AccelerateDecelerateInterpolator()) + .addTransition(new ChangeBounds()) + .addTransition(new ChangeTransform()) + .addListener(new TransitionListenerAdapter() { + @Override + public void onTransitionStart(@NonNull final Transition transition) { + for (Animator animator : additionalAnimators) { + animator.start(); + } + } + + @Override + public void onTransitionEnd(@NonNull final Transition transition) { + for (final Integer key : keySet) { + final View startView = startViews.get(key); + final View destView = destViews.get(key); + final ViewBounds viewBounds = viewBoundsMap.get(key); + if (startView == null || destView == null || viewBounds == null) return; + onEndSharedElementAnimation(startView, destView, viewBounds); + } + } + }); + view.post(() -> { + TransitionManager.beginDelayedTransition((ViewGroup) view, transitionSet); + for (final Integer key : keySet) { + final View startView = startViews.get(key); + final View destView = destViews.get(key); + final ViewBounds viewBounds = viewBoundsMap.get(key); + if (startView == null || destView == null || viewBounds == null) return; + onBeforeSharedElementAnimation(startView, destView, viewBounds); + setDestBounds(key); + } + }); + } + + private void setDestBounds(final int key) { + final View startView = startViews.get(key); + if (startView == null) return; + final View destView = destViews.get(key); + if (destView == null) return; + final ViewBounds viewBounds = viewBoundsMap.get(key); + if (viewBounds == null) return; + destView.setX((int) viewBounds.getDestX()); + destView.setY((int) viewBounds.getDestY()); + destView.setTranslationX(0); + destView.setTranslationY(0); + final ViewGroup.LayoutParams layoutParams = destView.getLayoutParams(); + layoutParams.height = viewBounds.getDestHeight(); + layoutParams.width = viewBounds.getDestWidth(); + destView.requestLayout(); + } + + protected void onBeforeSharedElementAnimation(@NonNull final View startView, + @NonNull final View destView, + @NonNull final ViewBounds viewBounds) {} + + protected void onEndSharedElementAnimation(@NonNull final View startView, + @NonNull final View destView, + @NonNull final ViewBounds viewBounds) {} + + private void setupInitialBounds(@NonNull final View startView, @NonNull final View destView) { + final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() { + private boolean firstPassDone; + + @Override + public boolean onPreDraw() { + destView.getViewTreeObserver().removeOnPreDrawListener(this); + if (!firstPassDone) { + getViewBounds(startView, destView, this); + firstPassDone = true; + return false; + } + final int[] location = new int[2]; + startView.getLocationOnScreen(location); + final int initX = location[0]; + final int initY = location[1]; + destView.setX(initX); + destView.setY(initY - Utils.getStatusBarHeight(getContext())); + boundsCalculatedCount++; + if (startCalled) { + startPostponedEnterTransition(); + } + return true; + } + }; + destView.getViewTreeObserver().addOnPreDrawListener(preDrawListener); + } + + private void getViewBounds(@NonNull final View startView, + @NonNull final View destView, + @NonNull final ViewTreeObserver.OnPreDrawListener preDrawListener) { + final ViewBounds viewBounds = new ViewBounds(); + viewBounds.setDestWidth(destView.getWidth()); + viewBounds.setDestHeight(destView.getHeight()); + viewBounds.setDestX(destView.getX()); + viewBounds.setDestY(destView.getY()); + + final Rect destBounds = new Rect(); + destView.getDrawingRect(destBounds); + viewBounds.setDestBounds(destBounds); + + final ViewGroup.LayoutParams layoutParams = destView.getLayoutParams(); + + final Rect startBounds = new Rect(); + startView.getDrawingRect(startBounds); + viewBounds.setStartBounds(startBounds); + + final int key = destView.hashCode(); + viewBoundsMap.put(key, viewBounds); + + layoutParams.height = startView.getHeight(); + layoutParams.width = startView.getWidth(); + + destView.getViewTreeObserver().addOnPreDrawListener(preDrawListener); + destView.requestLayout(); + } + + // private void animateBounds(@NonNull final View startView, + // @NonNull final View destView, + // @NonNull final ViewBounds viewBounds) { + // final ValueAnimator heightAnimator = ObjectAnimator.ofInt(startView.getHeight(), viewBounds.getDestHeight()); + // final ValueAnimator widthAnimator = ObjectAnimator.ofInt(startView.getWidth(), viewBounds.getDestWidth()); + // heightAnimator.setDuration(DURATION); + // widthAnimator.setDuration(DURATION); + // additionalAnimators.add(heightAnimator); + // additionalAnimators.add(widthAnimator); + // heightAnimator.addUpdateListener(animation -> { + // ViewGroup.LayoutParams params = destView.getLayoutParams(); + // params.height = (int) animation.getAnimatedValue(); + // destView.requestLayout(); + // }); + // widthAnimator.addUpdateListener(animation -> { + // ViewGroup.LayoutParams params = destView.getLayoutParams(); + // params.width = (int) animation.getAnimatedValue(); + // destView.requestLayout(); + // }); + // onBeforeSharedElementAnimation(startView, destView, viewBounds); + // final float destX = viewBounds.getDestX(); + // final float destY = viewBounds.getDestY(); + // final AnimatorSet animatorSet = new AnimatorSet(); + // animatorSet.addListener(new AnimatorListenerAdapter() { + // @Override + // public void onAnimationEnd(final Animator animation) { + // animationEnded(startView, destView, viewBounds); + // } + // }); + // + // destView.animate() + // .x(destX) + // .y(destY) + // .setDuration(DURATION) + // .withStartAction(() -> { + // if (!additionalAnimatorsStarted && additionalAnimators.size() > 0) { + // additionalAnimatorsStarted = true; + // animatorSet.playTogether(additionalAnimators); + // animatorSet.start(); + // } + // }) + // .withEndAction(() -> animationEnded(startView, destView, viewBounds)) + // .start(); + // } + + // private int endCount = 0; + // private void animationEnded(final View startView, final View destView, final ViewBounds viewBounds) { + // ++endCount; + // if (endCount != startViews.size() + 1) return; + // onEndSharedElementAnimation(startView, destView, viewBounds); + // } + + protected void addAnimator(@NonNull final Animator animator) { + additionalAnimators.add(animator); + } + + protected static class ViewBounds { + private float destY; + private float destX; + private int destHeight; + private int destWidth; + private Rect startBounds; + private Rect destBounds; + + public ViewBounds() {} + + public float getDestY() { + return destY; + } + + public void setDestY(final float destY) { + this.destY = destY; + } + + public float getDestX() { + return destX; + } + + public void setDestX(final float destX) { + this.destX = destX; + } + + public int getDestHeight() { + return destHeight; + } + + public void setDestHeight(final int destHeight) { + this.destHeight = destHeight; + } + + public int getDestWidth() { + return destWidth; + } + + public void setDestWidth(final int destWidth) { + this.destWidth = destWidth; + } + + public Rect getStartBounds() { + return startBounds; + } + + public void setStartBounds(final Rect startBounds) { + this.startBounds = startBounds; + } + + public Rect getDestBounds() { + return destBounds; + } + + public void setDestBounds(final Rect destBounds) { + this.destBounds = destBounds; + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + startViews.clear(); + destViews.clear(); + viewBoundsMap.clear(); + additionalAnimators.clear(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/SquareImageView.java b/app/src/main/java/awais/instagrabber/customviews/SquareImageView.java new file mode 100644 index 0000000..c46fd81 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/SquareImageView.java @@ -0,0 +1,26 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +public class SquareImageView extends AppCompatImageView { + public SquareImageView(final Context context) { + super(context); + } + + public SquareImageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public SquareImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + //noinspection SuspiciousNameCombination + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/TextViewDrawableSize.java b/app/src/main/java/awais/instagrabber/customviews/TextViewDrawableSize.java new file mode 100644 index 0000000..d4c96b8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/TextViewDrawableSize.java @@ -0,0 +1,95 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.emoji.widget.EmojiAppCompatTextView; + +import awais.instagrabber.R; + +/** + * https://stackoverflow.com/a/31916731 + */ +public class TextViewDrawableSize extends EmojiAppCompatTextView { + + private int mDrawableWidth; + private int mDrawableHeight; + private boolean calledFromInit = false; + + public TextViewDrawableSize(final Context context) { + this(context, null); + } + + public TextViewDrawableSize(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public TextViewDrawableSize(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr); + } + + private void init(@NonNull final Context context, final AttributeSet attrs, final int defStyleAttr) { + final TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TextViewDrawableSize, defStyleAttr, 0); + + try { + mDrawableWidth = array.getDimensionPixelSize(R.styleable.TextViewDrawableSize_compoundDrawableWidth, -1); + mDrawableHeight = array.getDimensionPixelSize(R.styleable.TextViewDrawableSize_compoundDrawableHeight, -1); + } finally { + array.recycle(); + } + + if (mDrawableWidth > 0 || mDrawableHeight > 0) { + initCompoundDrawableSize(); + } + } + + private void initCompoundDrawableSize() { + final Drawable[] drawables = getCompoundDrawablesRelative(); + for (Drawable drawable : drawables) { + if (drawable == null) { + continue; + } + + final Rect realBounds = drawable.getBounds(); + float scaleFactor = drawable.getIntrinsicHeight() / (float) drawable.getIntrinsicWidth(); + + float drawableWidth = drawable.getIntrinsicWidth(); + float drawableHeight = drawable.getIntrinsicHeight(); + + if (mDrawableWidth > 0) { + // save scale factor of image + if (drawableWidth > mDrawableWidth) { + drawableWidth = mDrawableWidth; + drawableHeight = drawableWidth * scaleFactor; + } + } + if (mDrawableHeight > 0) { + // save scale factor of image + if (drawableHeight > mDrawableHeight) { + drawableHeight = mDrawableHeight; + drawableWidth = drawableHeight / scaleFactor; + } + } + + realBounds.right = realBounds.left + Math.round(drawableWidth); + realBounds.bottom = realBounds.top + Math.round(drawableHeight); + + drawable.setBounds(realBounds); + } + setCompoundDrawablesRelative(drawables[0], drawables[1], drawables[2], drawables[3]); + } + + public void setCompoundDrawablesRelativeWithSize(@Nullable final Drawable start, + @Nullable final Drawable top, + @Nullable final Drawable end, + @Nullable final Drawable bottom) { + setCompoundDrawablesRelative(start, top, end, bottom); + initCompoundDrawableSize(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/Tooltip.java b/app/src/main/java/awais/instagrabber/customviews/Tooltip.java new file mode 100644 index 0000000..709f23b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/Tooltip.java @@ -0,0 +1,118 @@ +package awais.instagrabber.customviews; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewPropertyAnimator; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; + +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.utils.ViewUtils; + +import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; + +public class Tooltip extends AppCompatTextView { + + private View anchor; + private ViewPropertyAnimator animator; + private boolean showing; + + private final AppExecutors appExecutors = AppExecutors.INSTANCE; + private final Runnable dismissRunnable = () -> { + animator = animate().alpha(0).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + setVisibility(View.GONE); + } + }).setDuration(300); + animator.start(); + }; + + public Tooltip(@NonNull Context context, @NonNull ViewGroup parentView, int backgroundColor, int textColor) { + super(context); + setBackgroundDrawable(ViewUtils.createRoundRectDrawable(Utils.convertDpToPx(3), backgroundColor)); + setTextColor(textColor); + setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14); + setPadding(Utils.convertDpToPx(8), Utils.convertDpToPx(7), Utils.convertDpToPx(8), Utils.convertDpToPx(7)); + setGravity(Gravity.CENTER_VERTICAL); + parentView.addView(this, ViewUtils.createFrame(WRAP_CONTENT, WRAP_CONTENT, Gravity.START | Gravity.TOP, 5, 0, 5, 3)); + setVisibility(GONE); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + updateTooltipPosition(); + } + + private void updateTooltipPosition() { + if (anchor == null) { + return; + } + int top = 0; + int left = 0; + + View containerView = (View) getParent(); + View view = anchor; + + while (view != containerView) { + top += view.getTop(); + left += view.getLeft(); + view = (View) view.getParent(); + } + int x = left + anchor.getWidth() / 2 - getMeasuredWidth() / 2; + if (x < 0) { + x = 0; + } else if (x + getMeasuredWidth() > containerView.getMeasuredWidth()) { + x = containerView.getMeasuredWidth() - getMeasuredWidth() - Utils.convertDpToPx(16); + } + setTranslationX(x); + + int y = top - getMeasuredHeight(); + setTranslationY(y); + } + + public void show(View anchor) { + if (anchor == null) { + return; + } + this.anchor = anchor; + updateTooltipPosition(); + showing = true; + + appExecutors.getMainThread().cancel(dismissRunnable); + appExecutors.getMainThread().execute(dismissRunnable, 2000); + if (animator != null) { + animator.setListener(null); + animator.cancel(); + animator = null; + } + if (getVisibility() != VISIBLE) { + setAlpha(0f); + setVisibility(VISIBLE); + animator = animate().setDuration(300).alpha(1f).setListener(null); + animator.start(); + } + } + + public void hide() { + if (showing) { + if (animator != null) { + animator.setListener(null); + animator.cancel(); + animator = null; + } + + appExecutors.getMainThread().cancel(dismissRunnable); + dismissRunnable.run(); + } + showing = false; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/UsernameTextView.java b/app/src/main/java/awais/instagrabber/customviews/UsernameTextView.java new file mode 100644 index 0000000..c2da60c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/UsernameTextView.java @@ -0,0 +1,77 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.util.AttributeSet; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatTextView; + +import awais.instagrabber.R; +import awais.instagrabber.utils.Utils; + +public class UsernameTextView extends AppCompatTextView { + private static final String TAG = UsernameTextView.class.getSimpleName(); + + private final int drawableSize = Utils.convertDpToPx(24); + + private boolean verified; + private VerticalImageSpan verifiedSpan; + + public UsernameTextView(@NonNull final Context context) { + this(context, null); + } + + public UsernameTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { + this(context, attrs, 0); + } + + public UsernameTextView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + try { + final Drawable verifiedDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.verified); + final Drawable drawable = verifiedDrawable.mutate(); + drawable.setBounds(0, 0, drawableSize, drawableSize); + verifiedSpan = new VerticalImageSpan(drawable); + } catch (Exception e) { + Log.e(TAG, "init: ", e); + } + } + + public void setUsername(final CharSequence username) { + setUsername(username, false); + } + + public void setUsername(final CharSequence username, final boolean verified) { + this.verified = verified; + final SpannableStringBuilder sb = new SpannableStringBuilder(username); + if (verified) { + try { + if (verifiedSpan != null) { + sb.append(" "); + sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + Log.e(TAG, "bind: ", e); + } + } + super.setText(sb); + } + + public boolean isVerified() { + return verified; + } + + public void setVerified(final boolean verified) { + setUsername(getText(), verified); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/VerticalDragHelper.java b/app/src/main/java/awais/instagrabber/customviews/VerticalDragHelper.java new file mode 100644 index 0000000..4420111 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/VerticalDragHelper.java @@ -0,0 +1,137 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewParent; + +import androidx.annotation.NonNull; + +public class VerticalDragHelper { + // private static final String TAG = "VerticalDragHelper"; + private static final double SWIPE_THRESHOLD_VELOCITY = 80; + + private final View view; + + private GestureDetector gestureDetector; + private Context context; + private double flingVelocity; + private OnVerticalDragListener onVerticalDragListener; + + private final GestureDetector.OnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() { + + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + view.performClick(); + return true; + } + + @Override + public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float velocityX, final float velocityY) { + double yDir = e1.getY() - e2.getY(); + // Log.d(TAG, "onFling: yDir: " + yDir); + if (yDir < -SWIPE_THRESHOLD_VELOCITY || yDir > SWIPE_THRESHOLD_VELOCITY) { + flingVelocity = yDir; + } + return super.onFling(e1, e2, velocityX, velocityY); + } + }; + + private float prevRawY; + private boolean isDragging; + private float prevRawX; + private float dX; + private float prevDY; + + public VerticalDragHelper(@NonNull final View view) { + this.view = view; + final Context context = view.getContext(); + if (context == null) return; + this.context = context; + init(); + } + + public void setOnVerticalDragListener(@NonNull final OnVerticalDragListener onVerticalDragListener) { + this.onVerticalDragListener = onVerticalDragListener; + } + + protected void init() { + gestureDetector = new GestureDetector(context, gestureListener); + } + + public boolean onDragTouch(final MotionEvent event) { + if (onVerticalDragListener == null) { + return false; + } + if (gestureDetector.onTouchEvent(event)) { + return true; + } + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + return true; + case MotionEvent.ACTION_MOVE: + boolean handled = false; + final float rawY = event.getRawY(); + final float dY = rawY - prevRawY; + if (!isDragging) { + final float rawX = event.getRawX(); + if (prevRawX != 0) { + dX = rawX - prevRawX; + } + prevRawX = rawX; + if (prevRawY != 0) { + final float dYAbs = Math.abs(dY - prevDY); + if (!isDragging && dYAbs < 50) { + final float abs = Math.abs(dY) - Math.abs(dX); + if (abs > 0) { + isDragging = true; + } + } + } + } + if (isDragging) { + final ViewParent parent = view.getParent(); + parent.requestDisallowInterceptTouchEvent(true); + onVerticalDragListener.onDrag(dY); + handled = true; + } + prevDY = dY; + prevRawY = rawY; + return handled; + case MotionEvent.ACTION_UP: + // Log.d(TAG, "onDragTouch: reset prevRawY"); + prevRawY = 0; + if (flingVelocity != 0) { + onVerticalDragListener.onFling(flingVelocity); + flingVelocity = 0; + isDragging = false; + return true; + } + if (isDragging) { + onVerticalDragListener.onDragEnd(); + isDragging = false; + return true; + } + return false; + default: + return false; + } + } + + public boolean isDragging() { + return isDragging; + } + + public boolean onGestureTouchEvent(final MotionEvent event) { + return gestureDetector.onTouchEvent(event); + } + + public interface OnVerticalDragListener { + void onDrag(final float dY); + + void onDragEnd(); + + void onFling(final double flingVelocity); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java b/app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java new file mode 100644 index 0000000..06c633f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java @@ -0,0 +1,76 @@ +package awais.instagrabber.customviews; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.style.ImageSpan; + +import androidx.annotation.NonNull; + +public class VerticalImageSpan extends ImageSpan { + + public VerticalImageSpan(final Drawable drawable) { + super(drawable); + } + + /** + * update the text line height + */ + @Override + public int getSize(@NonNull Paint paint, + CharSequence text, + int start, + int end, + Paint.FontMetricsInt fontMetricsInt) { + Drawable drawable = getDrawable(); + Rect rect = drawable.getBounds(); + if (fontMetricsInt != null) { + Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt(); + int fontHeight = fmPaint.descent - fmPaint.ascent; + int drHeight = rect.bottom - rect.top; + int centerY = fmPaint.ascent + fontHeight / 2; + + fontMetricsInt.ascent = centerY - drHeight / 2; + fontMetricsInt.top = fontMetricsInt.ascent; + fontMetricsInt.bottom = centerY + drHeight / 2; + fontMetricsInt.descent = fontMetricsInt.bottom; + } + return rect.right; + } + + /** + * see detail message in android.text.TextLine + * + * @param canvas the canvas, can be null if not rendering + * @param text the text to be draw + * @param start the text start position + * @param end the text end position + * @param x the edge of the replacement closest to the leading margin + * @param top the top of the line + * @param y the baseline + * @param bottom the bottom of the line + * @param paint the work paint + */ + @Override + public void draw(Canvas canvas, + CharSequence text, + int start, + int end, + float x, + int top, + int y, + int bottom, + Paint paint) { + Drawable drawable = getDrawable(); + canvas.save(); + Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt(); + int fontHeight = fmPaint.descent - fmPaint.ascent; + int centerY = y + fmPaint.descent - fontHeight / 2; + int transY = centerY - (drawable.getBounds().bottom - drawable.getBounds().top) / 2; + canvas.translate(x, transY); + drawable.draw(canvas); + canvas.restore(); + } + +} diff --git a/app/src/main/java/awais/instagrabber/customviews/VideoPlayerCallbackAdapter.java b/app/src/main/java/awais/instagrabber/customviews/VideoPlayerCallbackAdapter.java new file mode 100644 index 0000000..dd75c15 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/VideoPlayerCallbackAdapter.java @@ -0,0 +1,31 @@ +package awais.instagrabber.customviews; + +import com.google.android.exoplayer2.ui.StyledPlayerView; + +public class VideoPlayerCallbackAdapter implements VideoPlayerViewHelper.VideoPlayerCallback { + @Override + public void onThumbnailLoaded() {} + + @Override + public void onThumbnailClick() {} + + @Override + public void onPlayerViewLoaded() {} + + @Override + public void onPlay() {} + + @Override + public void onPause() {} + + @Override + public void onRelease() {} + + @Override + public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) {} + + @Override + public boolean isInFullScreen() { + return false; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/VideoPlayerViewHelper.java b/app/src/main/java/awais/instagrabber/customviews/VideoPlayerViewHelper.java new file mode 100644 index 0000000..6ce21a7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/VideoPlayerViewHelper.java @@ -0,0 +1,334 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.drawable.Animatable; +import android.net.Uri; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageButton; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.imagepipeline.image.ImageInfo; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioListener; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.StyledPlayerControlView; +import com.google.android.exoplayer2.ui.StyledPlayerView; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; +import awais.instagrabber.utils.Utils; + +public class VideoPlayerViewHelper implements Player.EventListener { + private static final String TAG = VideoPlayerViewHelper.class.getSimpleName(); + + private final Context context; + private final LayoutVideoPlayerWithThumbnailBinding binding; + private final float initialVolume; + private final float thumbnailAspectRatio; + private final String thumbnailUrl; + private final boolean loadPlayerOnClick; + private final VideoPlayerCallback videoPlayerCallback; + private final String videoUrl; + private final DefaultDataSourceFactory dataSourceFactory; + private SimpleExoPlayer player; + private AppCompatImageButton mute; + + private final AudioListener audioListener = new AudioListener() { + @Override + public void onVolumeChanged(final float volume) { + updateMuteIcon(volume); + } + }; + private final View.OnClickListener muteOnClickListener = v -> toggleMute(); + private Object layoutManager; + + public VideoPlayerViewHelper(@NonNull final Context context, + @NonNull final LayoutVideoPlayerWithThumbnailBinding binding, + @NonNull final String videoUrl, + final float initialVolume, + final float thumbnailAspectRatio, + final String thumbnailUrl, + final boolean loadPlayerOnClick, + final VideoPlayerCallback videoPlayerCallback) { + this.context = context; + this.binding = binding; + this.initialVolume = initialVolume; + this.thumbnailAspectRatio = thumbnailAspectRatio; + this.thumbnailUrl = thumbnailUrl; + this.loadPlayerOnClick = loadPlayerOnClick; + this.videoPlayerCallback = videoPlayerCallback; + this.videoUrl = videoUrl; + this.dataSourceFactory = new DefaultDataSourceFactory(binding.getRoot().getContext(), "instagram"); + bind(); + } + + private void bind() { + binding.thumbnailParent.setOnClickListener(v -> { + if (videoPlayerCallback != null) { + videoPlayerCallback.onThumbnailClick(); + } + if (loadPlayerOnClick) { + loadPlayer(); + } + }); + setThumbnail(); + } + + private void setThumbnail() { + binding.thumbnail.setAspectRatio(thumbnailAspectRatio); + ImageRequest thumbnailRequest = null; + if (thumbnailUrl != null) { + thumbnailRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(thumbnailUrl)).build(); + } + final PipelineDraweeControllerBuilder builder = Fresco + .newDraweeControllerBuilder() + .setControllerListener(new BaseControllerListener() { + @Override + public void onFailure(final String id, final Throwable throwable) { + if (videoPlayerCallback != null) { + videoPlayerCallback.onThumbnailLoaded(); + } + } + + @Override + public void onFinalImageSet(final String id, + final ImageInfo imageInfo, + final Animatable animatable) { + if (videoPlayerCallback != null) { + videoPlayerCallback.onThumbnailLoaded(); + } + } + }); + if (thumbnailRequest != null) { + builder.setImageRequest(thumbnailRequest); + } + binding.thumbnail.setController(builder.build()); + } + + private void loadPlayer() { + if (videoUrl == null) return; + if (binding.getRoot().getDisplayedChild() == 0) { + binding.getRoot().showNext(); + } + if (videoPlayerCallback != null) { + videoPlayerCallback.onPlayerViewLoaded(); + } + player = (SimpleExoPlayer) binding.playerView.getPlayer(); + if (player != null) { + player.release(); + } + final ViewGroup.LayoutParams playerViewLayoutParams = binding.playerView.getLayoutParams(); + if (playerViewLayoutParams.height > Utils.displayMetrics.heightPixels * 0.8) { + playerViewLayoutParams.height = (int) (Utils.displayMetrics.heightPixels * 0.8); + } + player = new SimpleExoPlayer.Builder(context) + .setLooper(Looper.getMainLooper()) + .build(); + player.addListener(this); + player.addAudioListener(audioListener); + player.setVolume(initialVolume); + player.setPlayWhenReady(true); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + final ProgressiveMediaSource.Factory sourceFactory = new ProgressiveMediaSource.Factory(dataSourceFactory); + final MediaItem mediaItem = MediaItem.fromUri(videoUrl); + final ProgressiveMediaSource mediaSource = sourceFactory.createMediaSource(mediaItem); + player.setMediaSource(mediaSource); + player.prepare(); + binding.playerView.setPlayer(player); + binding.playerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); + binding.playerView.setShowNextButton(false); + binding.playerView.setShowPreviousButton(false); + binding.playerView.setControllerOnFullScreenModeChangedListener(isFullScreen -> { + if (videoPlayerCallback == null) return; + videoPlayerCallback.onFullScreenModeChanged(isFullScreen, binding.playerView); + }); + setupControllerView(); + } + + private void setupControllerView() { + try { + final StyledPlayerControlView controllerView = getStyledPlayerControlView(); + if (controllerView == null) return; + layoutManager = setControlViewLayoutManager(controllerView); + if (videoPlayerCallback != null && videoPlayerCallback.isInFullScreen()) { + setControllerViewToFullScreenMode(controllerView); + } + final ViewGroup exoBasicControls = controllerView.findViewById(R.id.exo_basic_controls); + if (exoBasicControls == null) return; + mute = new AppCompatImageButton(context); + final Resources resources = context.getResources(); + if (resources == null) return; + final int width = resources.getDimensionPixelSize(R.dimen.exo_small_icon_width); + final int height = resources.getDimensionPixelSize(R.dimen.exo_small_icon_height); + final int margin = resources.getDimensionPixelSize(R.dimen.exo_small_icon_horizontal_margin); + final int paddingHorizontal = resources.getDimensionPixelSize(R.dimen.exo_small_icon_padding_horizontal); + final int paddingVertical = resources.getDimensionPixelSize(R.dimen.exo_small_icon_padding_vertical); + final ViewGroup.MarginLayoutParams layoutParams = new ViewGroup.MarginLayoutParams(width, height); + layoutParams.setMargins(margin, 0, margin, 0); + mute.setLayoutParams(layoutParams); + mute.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical); + mute.setScaleType(ImageView.ScaleType.FIT_XY); + mute.setBackgroundResource(Utils.getAttrResId(context, android.R.attr.selectableItemBackground)); + mute.setImageTintList(ColorStateList.valueOf(resources.getColor(R.color.white))); + updateMuteIcon(player.getVolume()); + exoBasicControls.addView(mute, 0); + mute.setOnClickListener(muteOnClickListener); + } catch (Exception e) { + Log.e(TAG, "loadPlayer: ", e); + } + } + + @Nullable + private Object setControlViewLayoutManager(@NonNull final StyledPlayerControlView controllerView) + throws NoSuchFieldException, IllegalAccessException { + final Field controlViewLayoutManagerField = controllerView.getClass().getDeclaredField("controlViewLayoutManager"); + controlViewLayoutManagerField.setAccessible(true); + return controlViewLayoutManagerField.get(controllerView); + } + + private void setControllerViewToFullScreenMode(@NonNull final StyledPlayerControlView controllerView) + throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { + // Exoplayer doesn't expose the fullscreen state, so using reflection + final Field fullScreenButtonField = controllerView.getClass().getDeclaredField("fullScreenButton"); + fullScreenButtonField.setAccessible(true); + final ImageView fullScreenButton = (ImageView) fullScreenButtonField.get(controllerView); + final Field isFullScreen = controllerView.getClass().getDeclaredField("isFullScreen"); + isFullScreen.setAccessible(true); + isFullScreen.set(controllerView, true); + final Method updateFullScreenButtonForState = controllerView + .getClass() + .getDeclaredMethod("updateFullScreenButtonForState", ImageView.class, boolean.class); + updateFullScreenButtonForState.setAccessible(true); + updateFullScreenButtonForState.invoke(controllerView, fullScreenButton, true); + + } + + @Nullable + private StyledPlayerControlView getStyledPlayerControlView() throws NoSuchFieldException, IllegalAccessException { + final Field controller = binding.playerView.getClass().getDeclaredField("controller"); + controller.setAccessible(true); + return (StyledPlayerControlView) controller.get(binding.playerView); + } + + @Override + public void onTracksChanged(@NonNull TrackGroupArray trackGroups, @NonNull TrackSelectionArray trackSelections) { + if (trackGroups.isEmpty()) { + setHasAudio(false); + return; + } + boolean hasAudio = false; + for (int i = 0; i < trackGroups.length; i++) { + for (int g = 0; g < trackGroups.get(i).length; g++) { + final String sampleMimeType = trackGroups.get(i).getFormat(g).sampleMimeType; + if (sampleMimeType != null && sampleMimeType.contains("audio")) { + hasAudio = true; + break; + } + } + } + setHasAudio(hasAudio); + } + + private void setHasAudio(final boolean hasAudio) { + if (mute == null) return; + mute.setEnabled(hasAudio); + mute.setAlpha(hasAudio ? 1f : 0.5f); + updateMuteIcon(hasAudio ? 1f : 0f); + } + + private void updateMuteIcon(final float volume) { + if (mute == null) return; + if (volume == 0) { + mute.setImageResource(R.drawable.ic_volume_off_24); + return; + } + mute.setImageResource(R.drawable.ic_volume_up_24); + } + + @Override + public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason) { + if (videoPlayerCallback == null) return; + if (playWhenReady) { + videoPlayerCallback.onPlay(); + return; + } + videoPlayerCallback.onPause(); + } + + @Override + public void onPlayerError(@NonNull final ExoPlaybackException error) { + Log.e(TAG, "onPlayerError", error); + } + + private void toggleMute() { + if (player == null) return; + if (layoutManager != null) { + try { + final Method resetHideCallbacks = layoutManager.getClass().getDeclaredMethod("resetHideCallbacks"); + resetHideCallbacks.invoke(layoutManager); + } catch (Exception e) { + Log.e(TAG, "toggleMute: ", e); + } + } + final float vol = player.getVolume() == 0f ? 1f : 0f; + player.setVolume(vol); + } + + public void releasePlayer() { + if (videoPlayerCallback != null) { + videoPlayerCallback.onRelease(); + } + if (player != null) { + player.release(); + player = null; + } + } + + public void pause() { + if (player != null) { + player.pause(); + } + } + + public interface VideoPlayerCallback { + void onThumbnailLoaded(); + + void onThumbnailClick(); + + void onPlayerViewLoaded(); + + void onPlay(); + + void onPause(); + + void onRelease(); + + void onFullScreenModeChanged(boolean isFullScreen, final StyledPlayerView playerView); + + boolean isInFullScreen(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/AbstractAnimatedZoomableController.java b/app/src/main/java/awais/instagrabber/customviews/drawee/AbstractAnimatedZoomableController.java new file mode 100644 index 0000000..b473ef3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/AbstractAnimatedZoomableController.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.Nullable; + +import com.facebook.common.logging.FLog; + +/** + * Abstract class for ZoomableController that adds animation capabilities to + * DefaultZoomableController. + */ +public abstract class AbstractAnimatedZoomableController extends DefaultZoomableController { + + private boolean mIsAnimating; + private final float[] mStartValues = new float[9]; + private final float[] mStopValues = new float[9]; + private final float[] mCurrentValues = new float[9]; + private final Matrix mNewTransform = new Matrix(); + private final Matrix mWorkingTransform = new Matrix(); + + public AbstractAnimatedZoomableController(TransformGestureDetector transformGestureDetector) { + super(transformGestureDetector); + } + + @Override + public void reset() { + FLog.v(getLogTag(), "reset"); + stopAnimation(); + mWorkingTransform.reset(); + mNewTransform.reset(); + super.reset(); + } + + /** + * Returns true if the zoomable transform is identity matrix, and the controller is idle. + */ + @Override + public boolean isIdentity() { + return !isAnimating() && super.isIdentity(); + } + + /** + * Zooms to the desired scale and positions the image so that the given image point corresponds to + * the given view point. + * + *

If this method is called while an animation or gesture is already in progress, the current + * animation or gesture will be stopped first. + * + * @param scale desired scale, will be limited to {min, max} scale factor + * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) + * @param viewPoint 2D point in view's absolute coordinate system + */ + @Override + public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) { + zoomToPoint(scale, imagePoint, viewPoint, LIMIT_ALL, 0, null); + } + + /** + * Zooms to the desired scale and positions the image so that the given image point corresponds to + * the given view point. + * + *

If this method is called while an animation or gesture is already in progress, the current + * animation or gesture will be stopped first. + * + * @param scale desired scale, will be limited to {min, max} scale factor + * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) + * @param viewPoint 2D point in view's absolute coordinate system + * @param limitFlags whether to limit translation and/or scale. + * @param durationMs length of animation of the zoom, or 0 if no animation desired + * @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0 + */ + public void zoomToPoint( + float scale, + PointF imagePoint, + PointF viewPoint, + @LimitFlag int limitFlags, + long durationMs, + @Nullable Runnable onAnimationComplete) { + FLog.v(getLogTag(), "zoomToPoint: duration %d ms", durationMs); + calculateZoomToPointTransform(mNewTransform, scale, imagePoint, viewPoint, limitFlags); + setTransform(mNewTransform, durationMs, onAnimationComplete); + } + + /** + * Sets a new zoomable transformation and animates to it if desired. + * + *

If this method is called while an animation or gesture is already in progress, the current + * animation or gesture will be stopped first. + * + * @param newTransform new transform to make active + * @param durationMs duration of the animation, or 0 to not animate + * @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0 + */ + public void setTransform( + Matrix newTransform, long durationMs, @Nullable Runnable onAnimationComplete) { + FLog.v(getLogTag(), "setTransform: duration %d ms", durationMs); + if (durationMs <= 0) { + setTransformImmediate(newTransform); + } else { + setTransformAnimated(newTransform, durationMs, onAnimationComplete); + } + } + + private void setTransformImmediate(final Matrix newTransform) { + FLog.v(getLogTag(), "setTransformImmediate"); + stopAnimation(); + mWorkingTransform.set(newTransform); + super.setTransform(newTransform); + getDetector().restartGesture(); + } + + protected boolean isAnimating() { + return mIsAnimating; + } + + protected void setAnimating(boolean isAnimating) { + mIsAnimating = isAnimating; + } + + protected float[] getStartValues() { + return mStartValues; + } + + protected float[] getStopValues() { + return mStopValues; + } + + protected Matrix getWorkingTransform() { + return mWorkingTransform; + } + + @Override + public void onGestureBegin(TransformGestureDetector detector) { + FLog.v(getLogTag(), "onGestureBegin"); + stopAnimation(); + super.onGestureBegin(detector); + } + + @Override + public void onGestureUpdate(TransformGestureDetector detector) { + FLog.v(getLogTag(), "onGestureUpdate %s", isAnimating() ? "(ignored)" : ""); + if (isAnimating()) { + return; + } + super.onGestureUpdate(detector); + } + + protected void calculateInterpolation(Matrix outMatrix, float fraction) { + for (int i = 0; i < 9; i++) { + mCurrentValues[i] = (1 - fraction) * mStartValues[i] + fraction * mStopValues[i]; + } + outMatrix.setValues(mCurrentValues); + } + + public abstract void setTransformAnimated( + final Matrix newTransform, long durationMs, @Nullable final Runnable onAnimationComplete); + + protected abstract void stopAnimation(); + + protected abstract Class getLogTag(); +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/AnimatedZoomableController.java b/app/src/main/java/awais/instagrabber/customviews/drawee/AnimatedZoomableController.java new file mode 100644 index 0000000..963ac63 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/AnimatedZoomableController.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.graphics.Matrix; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.Nullable; + +import com.facebook.common.internal.Preconditions; +import com.facebook.common.logging.FLog; + + +/** + * ZoomableController that adds animation capabilities to DefaultZoomableController using standard + * Android animation classes + */ +public class AnimatedZoomableController extends AbstractAnimatedZoomableController { + + private static final Class TAG = AnimatedZoomableController.class; + + private final ValueAnimator mValueAnimator; + + public static AnimatedZoomableController newInstance() { + return new AnimatedZoomableController(TransformGestureDetector.newInstance()); + } + + @SuppressLint("NewApi") + public AnimatedZoomableController(TransformGestureDetector transformGestureDetector) { + super(transformGestureDetector); + mValueAnimator = ValueAnimator.ofFloat(0, 1); + mValueAnimator.setInterpolator(new DecelerateInterpolator()); + } + + @SuppressLint("NewApi") + @Override + public void setTransformAnimated( + final Matrix newTransform, long durationMs, @Nullable final Runnable onAnimationComplete) { + FLog.v(getLogTag(), "setTransformAnimated: duration %d ms", durationMs); + stopAnimation(); + Preconditions.checkArgument(durationMs > 0); + Preconditions.checkState(!isAnimating()); + setAnimating(true); + mValueAnimator.setDuration(durationMs); + getTransform().getValues(getStartValues()); + newTransform.getValues(getStopValues()); + mValueAnimator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + calculateInterpolation(getWorkingTransform(), (float) valueAnimator.getAnimatedValue()); + AnimatedZoomableController.super.setTransform(getWorkingTransform()); + } + }); + mValueAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(Animator animation) { + FLog.v(getLogTag(), "setTransformAnimated: animation cancelled"); + onAnimationStopped(); + } + + @Override + public void onAnimationEnd(Animator animation) { + FLog.v(getLogTag(), "setTransformAnimated: animation finished"); + onAnimationStopped(); + } + + private void onAnimationStopped() { + if (onAnimationComplete != null) { + onAnimationComplete.run(); + } + setAnimating(false); + getDetector().restartGesture(); + } + }); + mValueAnimator.start(); + } + + @SuppressLint("NewApi") + @Override + public void stopAnimation() { + if (!isAnimating()) { + return; + } + FLog.v(getLogTag(), "stopAnimation"); + mValueAnimator.cancel(); + mValueAnimator.removeAllUpdateListeners(); + mValueAnimator.removeAllListeners(); + } + + @Override + protected Class getLogTag() { + return TAG; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/DefaultZoomableController.java b/app/src/main/java/awais/instagrabber/customviews/drawee/DefaultZoomableController.java new file mode 100644 index 0000000..7e83dc2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/DefaultZoomableController.java @@ -0,0 +1,720 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.graphics.RectF; +import android.view.MotionEvent; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; + +import com.facebook.common.logging.FLog; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Zoomable controller that calculates transformation based on touch events. + */ +public class DefaultZoomableController + implements ZoomableController, TransformGestureDetector.Listener { + + /** + * Interface for handling call backs when the image bounds are set. + */ + public interface ImageBoundsListener { + void onImageBoundsSet(RectF imageBounds); + } + + @IntDef( + flag = true, + value = {LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL}) + @Retention(RetentionPolicy.SOURCE) + public @interface LimitFlag {} + + public static final int LIMIT_NONE = 0; + public static final int LIMIT_TRANSLATION_X = 1; + public static final int LIMIT_TRANSLATION_Y = 2; + public static final int LIMIT_SCALE = 4; + public static final int LIMIT_ALL = LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y | LIMIT_SCALE; + + private static final float EPS = 1e-3f; + + private static final Class TAG = DefaultZoomableController.class; + + private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1); + + private TransformGestureDetector mGestureDetector; + + private @Nullable + ImageBoundsListener mImageBoundsListener; + + private @Nullable + Listener mListener = null; + + private boolean mIsEnabled = false; + private boolean mIsRotationEnabled = false; + private boolean mIsScaleEnabled = true; + private boolean mIsTranslationEnabled = true; + private boolean mIsGestureZoomEnabled = true; + + private float mMinScaleFactor = 1.0f; + private float mMaxScaleFactor = 2.0f; + + // View bounds, in view-absolute coordinates + private final RectF mViewBounds = new RectF(); + // Non-transformed image bounds, in view-absolute coordinates + private final RectF mImageBounds = new RectF(); + // Transformed image bounds, in view-absolute coordinates + private final RectF mTransformedImageBounds = new RectF(); + + private final Matrix mPreviousTransform = new Matrix(); + private final Matrix mActiveTransform = new Matrix(); + private final Matrix mActiveTransformInverse = new Matrix(); + private final float[] mTempValues = new float[9]; + private final RectF mTempRect = new RectF(); + private boolean mWasTransformCorrected; + + public static DefaultZoomableController newInstance() { + return new DefaultZoomableController(TransformGestureDetector.newInstance()); + } + + public DefaultZoomableController(TransformGestureDetector gestureDetector) { + mGestureDetector = gestureDetector; + mGestureDetector.setListener(this); + } + + /** + * Rests the controller. + */ + public void reset() { + FLog.v(TAG, "reset"); + mGestureDetector.reset(); + mPreviousTransform.reset(); + mActiveTransform.reset(); + onTransformChanged(); + } + + /** + * Sets the zoomable listener. + */ + @Override + public void setListener(Listener listener) { + mListener = listener; + } + + /** + * Sets whether the controller is enabled or not. + */ + @Override + public void setEnabled(boolean enabled) { + mIsEnabled = enabled; + if (!enabled) { + reset(); + } + } + + /** + * Gets whether the controller is enabled or not. + */ + @Override + public boolean isEnabled() { + return mIsEnabled; + } + + /** + * Sets whether the rotation gesture is enabled or not. + */ + public void setRotationEnabled(boolean enabled) { + mIsRotationEnabled = enabled; + } + + /** + * Gets whether the rotation gesture is enabled or not. + */ + public boolean isRotationEnabled() { + return mIsRotationEnabled; + } + + /** + * Sets whether the scale gesture is enabled or not. + */ + public void setScaleEnabled(boolean enabled) { + mIsScaleEnabled = enabled; + } + + /** + * Gets whether the scale gesture is enabled or not. + */ + public boolean isScaleEnabled() { + return mIsScaleEnabled; + } + + /** + * Sets whether the translation gesture is enabled or not. + */ + public void setTranslationEnabled(boolean enabled) { + mIsTranslationEnabled = enabled; + } + + /** + * Gets whether the translations gesture is enabled or not. + */ + public boolean isTranslationEnabled() { + return mIsTranslationEnabled; + } + + /** + * Sets the minimum scale factor allowed. + * + *

Hierarchy's scaling (if any) is not taken into account. + */ + public void setMinScaleFactor(float minScaleFactor) { + mMinScaleFactor = minScaleFactor; + } + + /** + * Gets the minimum scale factor allowed. + */ + public float getMinScaleFactor() { + return mMinScaleFactor; + } + + /** + * Sets the maximum scale factor allowed. + * + *

Hierarchy's scaling (if any) is not taken into account. + */ + public void setMaxScaleFactor(float maxScaleFactor) { + mMaxScaleFactor = maxScaleFactor; + } + + /** + * Gets the maximum scale factor allowed. + */ + public float getMaxScaleFactor() { + return mMaxScaleFactor; + } + + /** + * Sets whether gesture zooms are enabled or not. + */ + public void setGestureZoomEnabled(boolean isGestureZoomEnabled) { + mIsGestureZoomEnabled = isGestureZoomEnabled; + } + + /** + * Gets whether gesture zooms are enabled or not. + */ + public boolean isGestureZoomEnabled() { + return mIsGestureZoomEnabled; + } + + /** + * Gets the current scale factor. + */ + @Override + public float getScaleFactor() { + return getMatrixScaleFactor(mActiveTransform); + } + + /** + * Sets the image bounds, in view-absolute coordinates. + */ + @Override + public void setImageBounds(RectF imageBounds) { + if (!imageBounds.equals(mImageBounds)) { + mImageBounds.set(imageBounds); + onTransformChanged(); + if (mImageBoundsListener != null) { + mImageBoundsListener.onImageBoundsSet(mImageBounds); + } + } + } + + /** + * Gets the non-transformed image bounds, in view-absolute coordinates. + */ + public RectF getImageBounds() { + return mImageBounds; + } + + /** + * Gets the transformed image bounds, in view-absolute coordinates + */ + private RectF getTransformedImageBounds() { + return mTransformedImageBounds; + } + + /** + * Sets the view bounds. + */ + @Override + public void setViewBounds(RectF viewBounds) { + mViewBounds.set(viewBounds); + } + + /** + * Gets the view bounds. + */ + public RectF getViewBounds() { + return mViewBounds; + } + + /** + * Sets the image bounds listener. + */ + public void setImageBoundsListener(@Nullable ImageBoundsListener imageBoundsListener) { + mImageBoundsListener = imageBoundsListener; + } + + /** + * Gets the image bounds listener. + */ + public @Nullable + ImageBoundsListener getImageBoundsListener() { + return mImageBoundsListener; + } + + /** + * Returns true if the zoomable transform is identity matrix. + */ + @Override + public boolean isIdentity() { + return isMatrixIdentity(mActiveTransform, 1e-3f); + } + + /** + * Returns true if the transform was corrected during the last update. + * + *

We should rename this method to `wasTransformedWithoutCorrection` and just return the + * internal flag directly. However, this requires interface change and negation of meaning. + */ + @Override + public boolean wasTransformCorrected() { + return mWasTransformCorrected; + } + + /** + * Gets the matrix that transforms image-absolute coordinates to view-absolute coordinates. The + * zoomable transformation is taken into account. + * + *

Internal matrix is exposed for performance reasons and is not to be modified by the callers. + */ + @Override + public Matrix getTransform() { + return mActiveTransform; + } + + /** + * Gets the matrix that transforms image-relative coordinates to view-absolute coordinates. The + * zoomable transformation is taken into account. + */ + public void getImageRelativeToViewAbsoluteTransform(Matrix outMatrix) { + outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, Matrix.ScaleToFit.FILL); + } + + /** + * Maps point from view-absolute to image-relative coordinates. This takes into account the + * zoomable transformation. + */ + public PointF mapViewToImage(PointF viewPoint) { + float[] points = mTempValues; + points[0] = viewPoint.x; + points[1] = viewPoint.y; + mActiveTransform.invert(mActiveTransformInverse); + mActiveTransformInverse.mapPoints(points, 0, points, 0, 1); + mapAbsoluteToRelative(points, points, 1); + return new PointF(points[0], points[1]); + } + + /** + * Maps point from image-relative to view-absolute coordinates. This takes into account the + * zoomable transformation. + */ + public PointF mapImageToView(PointF imagePoint) { + float[] points = mTempValues; + points[0] = imagePoint.x; + points[1] = imagePoint.y; + mapRelativeToAbsolute(points, points, 1); + mActiveTransform.mapPoints(points, 0, points, 0, 1); + return new PointF(points[0], points[1]); + } + + /** + * Maps array of 2D points from view-absolute to image-relative coordinates. This does NOT take + * into account the zoomable transformation. Points are represented by a float array of [x0, y0, + * x1, y1, ...]. + * + * @param destPoints destination array (may be the same as source array) + * @param srcPoints source array + * @param numPoints number of points to map + */ + private void mapAbsoluteToRelative(float[] destPoints, float[] srcPoints, int numPoints) { + for (int i = 0; i < numPoints; i++) { + destPoints[i * 2 + 0] = (srcPoints[i * 2 + 0] - mImageBounds.left) / mImageBounds.width(); + destPoints[i * 2 + 1] = (srcPoints[i * 2 + 1] - mImageBounds.top) / mImageBounds.height(); + } + } + + /** + * Maps array of 2D points from image-relative to view-absolute coordinates. This does NOT take + * into account the zoomable transformation. Points are represented by float array of [x0, y0, x1, + * y1, ...]. + * + * @param destPoints destination array (may be the same as source array) + * @param srcPoints source array + * @param numPoints number of points to map + */ + private void mapRelativeToAbsolute(float[] destPoints, float[] srcPoints, int numPoints) { + for (int i = 0; i < numPoints; i++) { + destPoints[i * 2 + 0] = srcPoints[i * 2 + 0] * mImageBounds.width() + mImageBounds.left; + destPoints[i * 2 + 1] = srcPoints[i * 2 + 1] * mImageBounds.height() + mImageBounds.top; + } + } + + /** + * Zooms to the desired scale and positions the image so that the given image point corresponds to + * the given view point. + * + * @param scale desired scale, will be limited to {min, max} scale factor + * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) + * @param viewPoint 2D point in view's absolute coordinate system + */ + public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) { + FLog.v(TAG, "zoomToPoint"); + calculateZoomToPointTransform(mActiveTransform, scale, imagePoint, viewPoint, LIMIT_ALL); + onTransformChanged(); + } + + /** + * Calculates the zoom transformation that would zoom to the desired scale and position the image + * so that the given image point corresponds to the given view point. + * + * @param outTransform the matrix to store the result to + * @param scale desired scale, will be limited to {min, max} scale factor + * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) + * @param viewPoint 2D point in view's absolute coordinate system + * @param limitFlags whether to limit translation and/or scale. + * @return whether or not the transform has been corrected due to limitation + */ + protected boolean calculateZoomToPointTransform( + Matrix outTransform, + float scale, + PointF imagePoint, + PointF viewPoint, + @LimitFlag int limitFlags) { + float[] viewAbsolute = mTempValues; + viewAbsolute[0] = imagePoint.x; + viewAbsolute[1] = imagePoint.y; + mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1); + float distanceX = viewPoint.x - viewAbsolute[0]; + float distanceY = viewPoint.y - viewAbsolute[1]; + boolean transformCorrected = false; + outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]); + transformCorrected |= limitScale(outTransform, viewAbsolute[0], viewAbsolute[1], limitFlags); + outTransform.postTranslate(distanceX, distanceY); + transformCorrected |= limitTranslation(outTransform, limitFlags); + return transformCorrected; + } + + /** + * Sets a new zoom transformation. + */ + public void setTransform(Matrix newTransform) { + FLog.v(TAG, "setTransform"); + mActiveTransform.set(newTransform); + onTransformChanged(); + } + + /** + * Gets the gesture detector. + */ + protected TransformGestureDetector getDetector() { + return mGestureDetector; + } + + /** + * Notifies controller of the received touch event. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + FLog.v(TAG, "onTouchEvent: action: ", event.getAction()); + if (mIsEnabled && mIsGestureZoomEnabled) { + return mGestureDetector.onTouchEvent(event); + } + return false; + } + + /* TransformGestureDetector.Listener methods */ + + @Override + public void onGestureBegin(TransformGestureDetector detector) { + FLog.v(TAG, "onGestureBegin"); + mPreviousTransform.set(mActiveTransform); + onTransformBegin(); + // We only received a touch down event so far, and so we don't know yet in which direction a + // future move event will follow. Therefore, if we can't scroll in all directions, we have to + // assume the worst case where the user tries to scroll out of edge, which would cause + // transformation to be corrected. + mWasTransformCorrected = !canScrollInAllDirection(); + } + + @Override + public void onGestureUpdate(TransformGestureDetector detector) { + FLog.v(TAG, "onGestureUpdate"); + boolean transformCorrected = calculateGestureTransform(mActiveTransform, LIMIT_ALL); + onTransformChanged(); + if (transformCorrected) { + mGestureDetector.restartGesture(); + } + // A transformation happened, but was it without correction? + mWasTransformCorrected = transformCorrected; + } + + @Override + public void onGestureEnd(TransformGestureDetector detector) { + FLog.v(TAG, "onGestureEnd"); + onTransformEnd(); + } + + /** + * Calculates the zoom transformation based on the current gesture. + * + * @param outTransform the matrix to store the result to + * @param limitTypes whether to limit translation and/or scale. + * @return whether or not the transform has been corrected due to limitation + */ + protected boolean calculateGestureTransform(Matrix outTransform, @LimitFlag int limitTypes) { + TransformGestureDetector detector = mGestureDetector; + boolean transformCorrected = false; + outTransform.set(mPreviousTransform); + if (mIsRotationEnabled) { + float angle = detector.getRotation() * (float) (180 / Math.PI); + outTransform.postRotate(angle, detector.getPivotX(), detector.getPivotY()); + } + if (mIsScaleEnabled) { + float scale = detector.getScale(); + outTransform.postScale(scale, scale, detector.getPivotX(), detector.getPivotY()); + } + transformCorrected |= + limitScale(outTransform, detector.getPivotX(), detector.getPivotY(), limitTypes); + if (mIsTranslationEnabled) { + outTransform.postTranslate(detector.getTranslationX(), detector.getTranslationY()); + } + transformCorrected |= limitTranslation(outTransform, limitTypes); + return transformCorrected; + } + + private void onTransformBegin() { + if (mListener != null && isEnabled()) { + mListener.onTransformBegin(mActiveTransform); + } + } + + private void onTransformChanged() { + mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds); + if (mListener != null && isEnabled()) { + mListener.onTransformChanged(mActiveTransform); + } + } + + private void onTransformEnd() { + if (mListener != null && isEnabled()) { + mListener.onTransformEnd(mActiveTransform); + } + } + + /** + * Keeps the scaling factor within the specified limits. + * + * @param pivotX x coordinate of the pivot point + * @param pivotY y coordinate of the pivot point + * @param limitTypes whether to limit scale. + * @return whether limiting has been applied or not + */ + private boolean limitScale( + Matrix transform, float pivotX, float pivotY, @LimitFlag int limitTypes) { + if (!shouldLimit(limitTypes, LIMIT_SCALE)) { + return false; + } + float currentScale = getMatrixScaleFactor(transform); + float targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor); + if (targetScale != currentScale) { + float scale = targetScale / currentScale; + transform.postScale(scale, scale, pivotX, pivotY); + return true; + } + return false; + } + + /** + * Limits the translation so that there are no empty spaces on the sides if possible. + * + *

The image is attempted to be centered within the view bounds if the transformed image is + * smaller. There will be no empty spaces within the view bounds if the transformed image is + * bigger. This applies to each dimension (horizontal and vertical) independently. + * + * @param limitTypes whether to limit translation along the specific axis. + * @return whether limiting has been applied or not + */ + private boolean limitTranslation(Matrix transform, @LimitFlag int limitTypes) { + if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y)) { + return false; + } + RectF b = mTempRect; + b.set(mImageBounds); + transform.mapRect(b); + final boolean shouldLimitX = shouldLimit(limitTypes, LIMIT_TRANSLATION_X); + float offsetLeft = shouldLimitX + ? getOffset(b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX()) + : 0; + float offsetTop = shouldLimit(limitTypes, LIMIT_TRANSLATION_Y) + ? getOffset(b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY()) + : 0; + if (mListener != null) { + mListener.onTranslationLimited(offsetLeft, offsetTop); + } + if (offsetLeft != 0 || offsetTop != 0) { + transform.postTranslate(offsetLeft, offsetTop); + return true; + } + return false; + } + + /** + * Checks whether the specified limit flag is present in the limits provided. + * + *

If the flag contains multiple flags together using a bitwise OR, this only checks that at + * least one of the flags is included. + * + * @param limits the limits to apply + * @param flag the limit flag(s) to check for + * @return true if the flag (or one of the flags) is included in the limits + */ + private static boolean shouldLimit(@LimitFlag int limits, @LimitFlag int flag) { + return (limits & flag) != LIMIT_NONE; + } + + /** + * Returns the offset necessary to make sure that: - the image is centered within the limit if the + * image is smaller than the limit - there is no empty space on left/right if the image is bigger + * than the limit + */ + private float getOffset( + float imageStart, float imageEnd, float limitStart, float limitEnd, float limitCenter) { + float imageWidth = imageEnd - imageStart, limitWidth = limitEnd - limitStart; + float limitInnerWidth = Math.min(limitCenter - limitStart, limitEnd - limitCenter) * 2; + // center if smaller than limitInnerWidth + if (imageWidth < limitInnerWidth) { + return limitCenter - (imageEnd + imageStart) / 2; + } + // to the edge if in between and limitCenter is not (limitLeft + limitRight) / 2 + if (imageWidth < limitWidth) { + if (limitCenter < (limitStart + limitEnd) / 2) { + return limitStart - imageStart; + } else { + return limitEnd - imageEnd; + } + } + // to the edge if larger than limitWidth and empty space visible + if (imageStart > limitStart) { + return limitStart - imageStart; + } + if (imageEnd < limitEnd) { + return limitEnd - imageEnd; + } + return 0; + } + + /** + * Limits the value to the given min and max range. + */ + private float limit(float value, float min, float max) { + return Math.min(Math.max(min, value), max); + } + + /** + * Gets the scale factor for the given matrix. This method assumes the equal scaling factor for X + * and Y axis. + */ + private float getMatrixScaleFactor(Matrix transform) { + transform.getValues(mTempValues); + return mTempValues[Matrix.MSCALE_X]; + } + + /** + * Same as {@code Matrix.isIdentity()}, but with tolerance {@code eps}. + */ + private boolean isMatrixIdentity(Matrix transform, float eps) { + // Checks whether the given matrix is close enough to the identity matrix: + // 1 0 0 + // 0 1 0 + // 0 0 1 + // Or equivalently to the zero matrix, after subtracting 1.0f from the diagonal elements: + // 0 0 0 + // 0 0 0 + // 0 0 0 + transform.getValues(mTempValues); + mTempValues[0] -= 1.0f; // m00 + mTempValues[4] -= 1.0f; // m11 + mTempValues[8] -= 1.0f; // m22 + for (int i = 0; i < 9; i++) { + if (Math.abs(mTempValues[i]) > eps) { + return false; + } + } + return true; + } + + /** + * Returns whether the scroll can happen in all directions. I.e. the image is not on any edge. + */ + private boolean canScrollInAllDirection() { + return mTransformedImageBounds.left < mViewBounds.left - EPS + && mTransformedImageBounds.top < mViewBounds.top - EPS + && mTransformedImageBounds.right > mViewBounds.right + EPS + && mTransformedImageBounds.bottom > mViewBounds.bottom + EPS; + } + + @Override + public int computeHorizontalScrollRange() { + return (int) mTransformedImageBounds.width(); + } + + @Override + public int computeHorizontalScrollOffset() { + return (int) (mViewBounds.left - mTransformedImageBounds.left); + } + + @Override + public int computeHorizontalScrollExtent() { + return (int) mViewBounds.width(); + } + + @Override + public int computeVerticalScrollRange() { + return (int) mTransformedImageBounds.height(); + } + + @Override + public int computeVerticalScrollOffset() { + return (int) (mViewBounds.top - mTransformedImageBounds.top); + } + + @Override + public int computeVerticalScrollExtent() { + return (int) mViewBounds.height(); + } + + public Listener getListener() { + return mListener; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/DoubleTapGestureListener.java b/app/src/main/java/awais/instagrabber/customviews/drawee/DoubleTapGestureListener.java new file mode 100644 index 0000000..59b9c7f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/DoubleTapGestureListener.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.graphics.PointF; +import android.view.GestureDetector; +import android.view.MotionEvent; + +/** + * Tap gesture listener for double tap to zoom / unzoom and double-tap-and-drag to zoom. + * + * @see ZoomableDraweeView#setTapListener(GestureDetector.SimpleOnGestureListener) + */ +public class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener { + private static final int DURATION_MS = 300; + private static final int DOUBLE_TAP_SCROLL_THRESHOLD = 20; + + private final ZoomableDraweeView mDraweeView; + private final PointF mDoubleTapViewPoint = new PointF(); + private final PointF mDoubleTapImagePoint = new PointF(); + private float mDoubleTapScale = 1; + private boolean mDoubleTapScroll = false; + + public DoubleTapGestureListener(ZoomableDraweeView zoomableDraweeView) { + mDraweeView = zoomableDraweeView; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + AbstractAnimatedZoomableController zc = + (AbstractAnimatedZoomableController) mDraweeView.getZoomableController(); + PointF vp = new PointF(e.getX(), e.getY()); + PointF ip = zc.mapViewToImage(vp); + switch (e.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + mDoubleTapViewPoint.set(vp); + mDoubleTapImagePoint.set(ip); + mDoubleTapScale = zc.getScaleFactor(); + break; + case MotionEvent.ACTION_MOVE: + mDoubleTapScroll = mDoubleTapScroll || shouldStartDoubleTapScroll(vp); + if (mDoubleTapScroll) { + float scale = calcScale(vp); + zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint); + } + break; + case MotionEvent.ACTION_UP: + if (mDoubleTapScroll) { + float scale = calcScale(vp); + zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint); + } else { + final float maxScale = zc.getMaxScaleFactor(); + final float minScale = zc.getMinScaleFactor(); + if (zc.getScaleFactor() < (maxScale + minScale) / 2) { + zc.zoomToPoint( + maxScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null); + } else { + zc.zoomToPoint( + minScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null); + } + } + mDoubleTapScroll = false; + break; + } + return true; + } + + private boolean shouldStartDoubleTapScroll(PointF viewPoint) { + double dist = + Math.hypot(viewPoint.x - mDoubleTapViewPoint.x, viewPoint.y - mDoubleTapViewPoint.y); + return dist > DOUBLE_TAP_SCROLL_THRESHOLD; + } + + private float calcScale(PointF currentViewPoint) { + float dy = (currentViewPoint.y - mDoubleTapViewPoint.y); + float t = 1 + Math.abs(dy) * 0.001f; + return (dy < 0) ? mDoubleTapScale / t : mDoubleTapScale * t; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/DraggableZoomableDraweeView.java b/app/src/main/java/awais/instagrabber/customviews/drawee/DraggableZoomableDraweeView.java new file mode 100644 index 0000000..ba3db62 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/DraggableZoomableDraweeView.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; + +import com.facebook.drawee.generic.GenericDraweeHierarchy; + +import awais.instagrabber.customviews.VerticalDragHelper; +import awais.instagrabber.customviews.VerticalDragHelper.OnVerticalDragListener; + +public class DraggableZoomableDraweeView extends ZoomableDraweeView { + private static final String TAG = "DraggableZoomableDV"; + + private VerticalDragHelper verticalDragHelper; + + public DraggableZoomableDraweeView(final Context context, final GenericDraweeHierarchy hierarchy) { + super(context, hierarchy); + verticalDragHelper = new VerticalDragHelper(this); + } + + public DraggableZoomableDraweeView(final Context context) { + super(context); + verticalDragHelper = new VerticalDragHelper(this); + } + + public DraggableZoomableDraweeView(final Context context, final AttributeSet attrs) { + super(context, attrs); + verticalDragHelper = new VerticalDragHelper(this); + } + + public DraggableZoomableDraweeView(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + verticalDragHelper = new VerticalDragHelper(this); + } + + public void setOnVerticalDragListener(@NonNull final OnVerticalDragListener onVerticalDragListener) { + verticalDragHelper.setOnVerticalDragListener(onVerticalDragListener); + } + + private int lastPointerCount; + private int lastNewPointerCount; + private boolean wasTransformCorrected; + + // @Override + // protected void onTransformEnd(final Matrix transform) { + // super.onTransformEnd(transform); + // final AnimatedZoomableController zoomableController = (AnimatedZoomableController) getZoomableController(); + // final TransformGestureDetector detector = zoomableController.getDetector(); + // lastNewPointerCount = detector.getNewPointerCount(); + // lastPointerCount = detector.getPointerCount(); + // } + // + // @Override + // protected void onTranslationLimited(final float offsetLeft, final float offsetTop) { + // super.onTranslationLimited(offsetLeft, offsetTop); + // wasTransformCorrected = offsetTop != 0; + // } + + // @SuppressLint("ClickableViewAccessibility") + // @Override + // public boolean onTouchEvent(final MotionEvent event) { + // boolean superResult = false; + // superResult = super.onTouchEvent(event); + // if (verticalDragHelper.isDragging()) { + // final boolean onDragTouch = verticalDragHelper.onDragTouch(event); + // if (onDragTouch) { + // return true; + // } + // } + // if (!verticalDragHelper.isDragging()) { + // superResult = super.onTouchEvent(event); + // if (wasTransformCorrected + // && (lastPointerCount == 1 || lastPointerCount == 0) + // && (lastNewPointerCount == 1 || lastNewPointerCount == 0)) { + // final boolean onDragTouch = verticalDragHelper.onDragTouch(event); + // if (onDragTouch) { + // return true; + // } + // } + // } + // final boolean gestureListenerResult = verticalDragHelper.onGestureTouchEvent(event); + // if (gestureListenerResult) { + // return true; + // } + // return superResult; + // } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/GestureListenerWrapper.java b/app/src/main/java/awais/instagrabber/customviews/drawee/GestureListenerWrapper.java new file mode 100644 index 0000000..933cf69 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/GestureListenerWrapper.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.view.GestureDetector; +import android.view.MotionEvent; + +/** + * Wrapper for SimpleOnGestureListener as GestureDetector does not allow changing its listener. + */ +public class GestureListenerWrapper extends GestureDetector.SimpleOnGestureListener { + + private GestureDetector.SimpleOnGestureListener mDelegate; + + public GestureListenerWrapper() { + mDelegate = new GestureDetector.SimpleOnGestureListener(); + } + + public void setListener(GestureDetector.SimpleOnGestureListener listener) { + mDelegate = listener; + } + + @Override + public void onLongPress(MotionEvent e) { + mDelegate.onLongPress(e); + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return mDelegate.onScroll(e1, e2, distanceX, distanceY); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return mDelegate.onFling(e1, e2, velocityX, velocityY); + } + + @Override + public void onShowPress(MotionEvent e) { + mDelegate.onShowPress(e); + } + + @Override + public boolean onDown(MotionEvent e) { + return mDelegate.onDown(e); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + return mDelegate.onDoubleTap(e); + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return mDelegate.onDoubleTapEvent(e); + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return mDelegate.onSingleTapConfirmed(e); + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return mDelegate.onSingleTapUp(e); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/MultiGestureListener.java b/app/src/main/java/awais/instagrabber/customviews/drawee/MultiGestureListener.java new file mode 100644 index 0000000..e19d0f3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/MultiGestureListener.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.view.GestureDetector; +import android.view.MotionEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Gesture listener that allows multiple child listeners to be added and notified about gesture + * events. + * + *

NOTE: The order of the listeners is important. Listeners can consume gesture events. For + * example, if one of the child listeners consumes {@link #onLongPress(MotionEvent)} (the listener + * returned true), subsequent listeners will not be notified about the event any more since it has + * been consumed. + */ +public class MultiGestureListener extends GestureDetector.SimpleOnGestureListener { + + private final List mListeners = new ArrayList<>(); + + /** + * Adds a listener to the multi gesture listener. + * + *

NOTE: The order of the listeners is important since gesture events can be consumed. + * + * @param listener the listener to be added + */ + public synchronized void addListener(GestureDetector.SimpleOnGestureListener listener) { + mListeners.add(listener); + } + + /** + * Removes the given listener so that it will not be notified about future events. + * + *

NOTE: The order of the listeners is important since gesture events can be consumed. + * + * @param listener the listener to remove + */ + public synchronized void removeListener(GestureDetector.SimpleOnGestureListener listener) { + mListeners.remove(listener); + } + + @Override + public synchronized boolean onSingleTapUp(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onSingleTapUp(e)) { + return true; + } + } + return false; + } + + @Override + public synchronized void onLongPress(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + mListeners.get(i).onLongPress(e); + } + } + + @Override + public synchronized boolean onScroll( + MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onScroll(e1, e2, distanceX, distanceY)) { + return true; + } + } + return false; + } + + @Override + public synchronized boolean onFling( + MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onFling(e1, e2, velocityX, velocityY)) { + return true; + } + } + return false; + } + + @Override + public synchronized void onShowPress(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + mListeners.get(i).onShowPress(e); + } + } + + @Override + public synchronized boolean onDown(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onDown(e)) { + return true; + } + } + return false; + } + + @Override + public synchronized boolean onDoubleTap(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onDoubleTap(e)) { + return true; + } + } + return false; + } + + @Override + public synchronized boolean onDoubleTapEvent(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onDoubleTapEvent(e)) { + return true; + } + } + return false; + } + + @Override + public synchronized boolean onSingleTapConfirmed(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onSingleTapConfirmed(e)) { + return true; + } + } + return false; + } + + @Override + public synchronized boolean onContextClick(MotionEvent e) { + final int size = mListeners.size(); + for (int i = 0; i < size; i++) { + if (mListeners.get(i).onContextClick(e)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/MultiPointerGestureDetector.java b/app/src/main/java/awais/instagrabber/customviews/drawee/MultiPointerGestureDetector.java new file mode 100644 index 0000000..8c453dd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/MultiPointerGestureDetector.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.view.MotionEvent; + +/** + * Component that detects and tracks multiple pointers based on touch events. + * + *

Each time a pointer gets pressed or released, the current gesture (if any) will end, and a new + * one will be started (if there are still pressed pointers left). It is guaranteed that the number + * of pointers within the single gesture will remain the same during the whole gesture. + */ +public class MultiPointerGestureDetector { + + /** + * The listener for receiving notifications when gestures occur. + */ + public interface Listener { + /** + * A callback called right before the gesture is about to start. + */ + public void onGestureBegin(MultiPointerGestureDetector detector); + + /** + * A callback called each time the gesture gets updated. + */ + public void onGestureUpdate(MultiPointerGestureDetector detector); + + /** + * A callback called right after the gesture has finished. + */ + public void onGestureEnd(MultiPointerGestureDetector detector); + } + + private static final int MAX_POINTERS = 2; + + private boolean mGestureInProgress; + private int mPointerCount; + private int mNewPointerCount; + private final int mId[] = new int[MAX_POINTERS]; + private final float mStartX[] = new float[MAX_POINTERS]; + private final float mStartY[] = new float[MAX_POINTERS]; + private final float mCurrentX[] = new float[MAX_POINTERS]; + private final float mCurrentY[] = new float[MAX_POINTERS]; + + private Listener mListener = null; + + public MultiPointerGestureDetector() { + reset(); + } + + /** + * Factory method that creates a new instance of MultiPointerGestureDetector + */ + public static MultiPointerGestureDetector newInstance() { + return new MultiPointerGestureDetector(); + } + + /** + * Sets the listener. + * + * @param listener listener to set + */ + public void setListener(Listener listener) { + mListener = listener; + } + + /** + * Resets the component to the initial state. + */ + public void reset() { + mGestureInProgress = false; + mPointerCount = 0; + for (int i = 0; i < MAX_POINTERS; i++) { + mId[i] = MotionEvent.INVALID_POINTER_ID; + } + } + + /** + * This method can be overridden in order to perform threshold check or something similar. + * + * @return whether or not to start a new gesture + */ + protected boolean shouldStartGesture() { + return true; + } + + /** + * Starts a new gesture and calls the listener just before starting it. + */ + private void startGesture() { + if (!mGestureInProgress) { + if (mListener != null) { + mListener.onGestureBegin(this); + } + mGestureInProgress = true; + } + } + + /** + * Stops the current gesture and calls the listener right after stopping it. + */ + private void stopGesture() { + if (mGestureInProgress) { + mGestureInProgress = false; + if (mListener != null) { + mListener.onGestureEnd(this); + } + } + } + + /** + * Gets the index of the i-th pressed pointer. Normally, the index will be equal to i, except in + * the case when the pointer is released. + * + * @return index of the specified pointer or -1 if not found (i.e. not enough pointers are down) + */ + private int getPressedPointerIndex(MotionEvent event, int i) { + final int count = event.getPointerCount(); + final int action = event.getActionMasked(); + final int index = event.getActionIndex(); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { + if (i >= index) { + i++; + } + } + return (i < count) ? i : -1; + } + + /** + * Gets the number of pressed pointers (fingers down). + */ + private static int getPressedPointerCount(MotionEvent event) { + int count = event.getPointerCount(); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { + count--; + } + return count; + } + + private void updatePointersOnTap(MotionEvent event) { + mPointerCount = 0; + for (int i = 0; i < MAX_POINTERS; i++) { + int index = getPressedPointerIndex(event, i); + if (index == -1) { + mId[i] = MotionEvent.INVALID_POINTER_ID; + } else { + mId[i] = event.getPointerId(index); + mCurrentX[i] = mStartX[i] = event.getX(index); + mCurrentY[i] = mStartY[i] = event.getY(index); + mPointerCount++; + } + } + } + + private void updatePointersOnMove(MotionEvent event) { + for (int i = 0; i < MAX_POINTERS; i++) { + int index = event.findPointerIndex(mId[i]); + if (index != -1) { + mCurrentX[i] = event.getX(index); + mCurrentY[i] = event.getY(index); + } + } + } + + /** + * Handles the given motion event. + * + * @param event event to handle + * @return whether or not the event was handled + */ + public boolean onTouchEvent(final MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_MOVE: { + // update pointers + updatePointersOnMove(event); + // start a new gesture if not already started + if (!mGestureInProgress && mPointerCount > 0 && shouldStartGesture()) { + startGesture(); + } + // notify listener + if (mGestureInProgress && mListener != null) { + mListener.onGestureUpdate(this); + } + break; + } + + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: { + // restart gesture whenever the number of pointers changes + mNewPointerCount = getPressedPointerCount(event); + stopGesture(); + updatePointersOnTap(event); + if (mPointerCount > 0 && shouldStartGesture()) { + startGesture(); + } + break; + } + + case MotionEvent.ACTION_CANCEL: { + mNewPointerCount = 0; + stopGesture(); + reset(); + break; + } + } + return true; + } + + /** + * Restarts the current gesture (if any). + */ + public void restartGesture() { + if (!mGestureInProgress) { + return; + } + stopGesture(); + for (int i = 0; i < MAX_POINTERS; i++) { + mStartX[i] = mCurrentX[i]; + mStartY[i] = mCurrentY[i]; + } + startGesture(); + } + + /** + * Gets whether there is a gesture in progress + */ + public boolean isGestureInProgress() { + return mGestureInProgress; + } + + /** + * Gets the number of pointers after the current gesture + */ + public int getNewPointerCount() { + return mNewPointerCount; + } + + /** + * Gets the number of pointers in the current gesture + */ + public int getPointerCount() { + return mPointerCount; + } + + /** + * Gets the start X coordinates for the all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + public float[] getStartX() { + return mStartX; + } + + /** + * Gets the start Y coordinates for the all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + public float[] getStartY() { + return mStartY; + } + + /** + * Gets the current X coordinates for the all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + public float[] getCurrentX() { + return mCurrentX; + } + + /** + * Gets the current Y coordinates for the all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + public float[] getCurrentY() { + return mCurrentY; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/MultiZoomableControllerListener.java b/app/src/main/java/awais/instagrabber/customviews/drawee/MultiZoomableControllerListener.java new file mode 100644 index 0000000..578c6ec --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/MultiZoomableControllerListener.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.graphics.Matrix; + +import java.util.ArrayList; +import java.util.List; + +/** + * An implementation of {@link ZoomableController.Listener} that allows multiple child listeners to + * be added and notified about {@link ZoomableController} events. + */ +public class MultiZoomableControllerListener implements ZoomableController.Listener { + + private final List mListeners = new ArrayList<>(); + + @Override + public synchronized void onTransformBegin(Matrix transform) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTransformBegin(transform); + } + } + + @Override + public synchronized void onTransformChanged(Matrix transform) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTransformChanged(transform); + } + } + + @Override + public synchronized void onTransformEnd(Matrix transform) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTransformEnd(transform); + } + } + + @Override + public void onTranslationLimited(final float offsetLeft, final float offsetTop) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTranslationLimited(offsetLeft, offsetTop); + } + } + + public synchronized void addListener(ZoomableController.Listener listener) { + mListeners.add(listener); + } + + public synchronized void removeListener(ZoomableController.Listener listener) { + mListeners.remove(listener); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/TransformGestureDetector.java b/app/src/main/java/awais/instagrabber/customviews/drawee/TransformGestureDetector.java new file mode 100644 index 0000000..b8a9518 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/TransformGestureDetector.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.view.MotionEvent; + +/** + * Component that detects translation, scale and rotation based on touch events. + * + *

This class notifies its listeners whenever a gesture begins, updates or ends. The instance of + * this detector is passed to the listeners, so it can be queried for pivot, translation, scale or + * rotation. + */ +public class TransformGestureDetector implements MultiPointerGestureDetector.Listener { + + /** + * The listener for receiving notifications when gestures occur. + */ + public interface Listener { + /** + * A callback called right before the gesture is about to start. + */ + public void onGestureBegin(TransformGestureDetector detector); + + /** + * A callback called each time the gesture gets updated. + */ + public void onGestureUpdate(TransformGestureDetector detector); + + /** + * A callback called right after the gesture has finished. + */ + public void onGestureEnd(TransformGestureDetector detector); + } + + private final MultiPointerGestureDetector mDetector; + + private Listener mListener = null; + + public TransformGestureDetector(MultiPointerGestureDetector multiPointerGestureDetector) { + mDetector = multiPointerGestureDetector; + mDetector.setListener(this); + } + + /** + * Factory method that creates a new instance of TransformGestureDetector + */ + public static TransformGestureDetector newInstance() { + return new TransformGestureDetector(MultiPointerGestureDetector.newInstance()); + } + + /** + * Sets the listener. + * + * @param listener listener to set + */ + public void setListener(Listener listener) { + mListener = listener; + } + + /** + * Resets the component to the initial state. + */ + public void reset() { + mDetector.reset(); + } + + /** + * Handles the given motion event. + * + * @param event event to handle + * @return whether or not the event was handled + */ + public boolean onTouchEvent(final MotionEvent event) { + return mDetector.onTouchEvent(event); + } + + @Override + public void onGestureBegin(MultiPointerGestureDetector detector) { + if (mListener != null) { + mListener.onGestureBegin(this); + } + } + + @Override + public void onGestureUpdate(MultiPointerGestureDetector detector) { + if (mListener != null) { + mListener.onGestureUpdate(this); + } + } + + @Override + public void onGestureEnd(MultiPointerGestureDetector detector) { + if (mListener != null) { + mListener.onGestureEnd(this); + } + } + + private float calcAverage(float[] arr, int len) { + float sum = 0; + for (int i = 0; i < len; i++) { + sum += arr[i]; + } + return (len > 0) ? sum / len : 0; + } + + /** + * Restarts the current gesture (if any). + */ + public void restartGesture() { + mDetector.restartGesture(); + } + + /** + * Gets whether there is a gesture in progress + */ + public boolean isGestureInProgress() { + return mDetector.isGestureInProgress(); + } + + /** + * Gets the number of pointers after the current gesture + */ + public int getNewPointerCount() { + return mDetector.getNewPointerCount(); + } + + /** + * Gets the number of pointers in the current gesture + */ + public int getPointerCount() { + return mDetector.getPointerCount(); + } + + /** + * Gets the X coordinate of the pivot point + */ + public float getPivotX() { + return calcAverage(mDetector.getStartX(), mDetector.getPointerCount()); + } + + /** + * Gets the Y coordinate of the pivot point + */ + public float getPivotY() { + return calcAverage(mDetector.getStartY(), mDetector.getPointerCount()); + } + + /** + * Gets the X component of the translation + */ + public float getTranslationX() { + return calcAverage(mDetector.getCurrentX(), mDetector.getPointerCount()) + - calcAverage(mDetector.getStartX(), mDetector.getPointerCount()); + } + + /** + * Gets the Y component of the translation + */ + public float getTranslationY() { + return calcAverage(mDetector.getCurrentY(), mDetector.getPointerCount()) + - calcAverage(mDetector.getStartY(), mDetector.getPointerCount()); + } + + /** + * Gets the scale + */ + public float getScale() { + if (mDetector.getPointerCount() < 2) { + return 1; + } else { + float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0]; + float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0]; + float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0]; + float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0]; + float startDist = (float) Math.hypot(startDeltaX, startDeltaY); + float currentDist = (float) Math.hypot(currentDeltaX, currentDeltaY); + return currentDist / startDist; + } + } + + /** + * Gets the rotation in radians + */ + public float getRotation() { + if (mDetector.getPointerCount() < 2) { + return 0; + } else { + float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0]; + float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0]; + float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0]; + float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0]; + float startAngle = (float) Math.atan2(startDeltaY, startDeltaX); + float currentAngle = (float) Math.atan2(currentDeltaY, currentDeltaX); + return currentAngle - startAngle; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableController.java b/app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableController.java new file mode 100644 index 0000000..dc31ea9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableController.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.graphics.Matrix; +import android.graphics.RectF; +import android.view.MotionEvent; + +/** + * Interface for implementing a controller that works with {@link ZoomableDraweeView} to control the + * zoom. + */ +public interface ZoomableController { + + /** + * Listener interface. + */ + interface Listener { + + /** + * Notifies the view that the transform began. + * + * @param transform the current transform matrix + */ + void onTransformBegin(Matrix transform); + + /** + * Notifies the view that the transform changed. + * + * @param transform the new matrix + */ + void onTransformChanged(Matrix transform); + + /** + * Notifies the view that the transform ended. + * + * @param transform the current transform matrix + */ + void onTransformEnd(Matrix transform); + + void onTranslationLimited(float offsetLeft, float offsetTop); + } + + /** + * Enables the controller. The controller is enabled when the image has been loaded. + * + * @param enabled whether to enable the controller + */ + void setEnabled(boolean enabled); + + /** + * Gets whether the controller is enabled. This should return the last value passed to {@link + * #setEnabled}. + * + * @return whether the controller is enabled. + */ + boolean isEnabled(); + + /** + * Sets the listener for the controller to call back when the matrix changes. + * + * @param listener the listener + */ + void setListener(Listener listener); + + /** + * Gets the current scale factor. A convenience method for calculating the scale from the + * transform. + * + * @return the current scale factor + */ + float getScaleFactor(); + + /** + * Returns true if the zoomable transform is identity matrix, and the controller is idle. + */ + boolean isIdentity(); + + /** + * Returns true if the transform was corrected during the last update. + * + *

This mainly happens when a gesture would cause the image to get out of limits and the + * transform gets corrected in order to prevent that. + */ + boolean wasTransformCorrected(); + + /** + * See {@link androidx.core.view.ScrollingView}. + */ + int computeHorizontalScrollRange(); + + int computeHorizontalScrollOffset(); + + int computeHorizontalScrollExtent(); + + int computeVerticalScrollRange(); + + int computeVerticalScrollOffset(); + + int computeVerticalScrollExtent(); + + /** + * Gets the current transform. + * + * @return the transform + */ + Matrix getTransform(); + + /** + * Sets the bounds of the image post transform prior to application of the zoomable + * transformation. + * + * @param imageBounds the bounds of the image + */ + void setImageBounds(RectF imageBounds); + + /** + * Sets the bounds of the view. + * + * @param viewBounds the bounds of the view + */ + void setViewBounds(RectF viewBounds); + + /** + * Allows the controller to handle a touch event. + * + * @param event the touch event + * @return whether the controller handled the event + */ + boolean onTouchEvent(MotionEvent event); +} diff --git a/app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableDraweeView.java b/app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableDraweeView.java new file mode 100644 index 0000000..d8b7b8f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableDraweeView.java @@ -0,0 +1,430 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package awais.instagrabber.customviews.drawee; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.graphics.drawable.Animatable; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ViewParent; + +import androidx.annotation.Nullable; +import androidx.core.view.ScrollingView; + +import com.facebook.common.internal.Preconditions; +import com.facebook.common.logging.FLog; +import com.facebook.drawee.controller.AbstractDraweeController; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.controller.ControllerListener; +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.generic.GenericDraweeHierarchyInflater; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.drawee.view.DraweeView; + + +/** + * DraweeView that has zoomable capabilities. + * + *

Once the image loads, pinch-to-zoom and translation gestures are enabled. + */ +public class ZoomableDraweeView extends DraweeView + implements ScrollingView { + + private static final Class TAG = ZoomableDraweeView.class; + + private static final float HUGE_IMAGE_SCALE_FACTOR_THRESHOLD = 1.1f; + + private final RectF mImageBounds = new RectF(); + private final RectF mViewBounds = new RectF(); + + private DraweeController mHugeImageController; + private ZoomableController mZoomableController; + private GestureDetector mTapGestureDetector; + private boolean mAllowTouchInterceptionWhileZoomed = false; + + private boolean mIsDialtoneEnabled = false; + private boolean mZoomingEnabled = true; + + private final ControllerListener mControllerListener = + new BaseControllerListener() { + @Override + public void onFinalImageSet( + String id, @Nullable Object imageInfo, @Nullable Animatable animatable) { + ZoomableDraweeView.this.onFinalImageSet(); + } + + @Override + public void onRelease(String id) { + ZoomableDraweeView.this.onRelease(); + } + }; + + private final ZoomableController.Listener mZoomableListener = + new ZoomableController.Listener() { + @Override + public void onTransformBegin(Matrix transform) { + ZoomableDraweeView.this.onTransformBegin(transform); + } + + @Override + public void onTransformChanged(Matrix transform) { + ZoomableDraweeView.this.onTransformChanged(transform); + } + + @Override + public void onTransformEnd(Matrix transform) { + ZoomableDraweeView.this.onTransformEnd(transform); + } + + @Override + public void onTranslationLimited(final float offsetLeft, final float offsetTop) { + ZoomableDraweeView.this.onTranslationLimited(offsetLeft, offsetTop); + } + }; + + private final GestureListenerWrapper mTapListenerWrapper = new GestureListenerWrapper(); + + public ZoomableDraweeView(Context context, GenericDraweeHierarchy hierarchy) { + super(context); + setHierarchy(hierarchy); + init(); + } + + public ZoomableDraweeView(Context context) { + super(context); + inflateHierarchy(context, null); + init(); + } + + public ZoomableDraweeView(Context context, AttributeSet attrs) { + super(context, attrs); + inflateHierarchy(context, attrs); + init(); + } + + public ZoomableDraweeView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + inflateHierarchy(context, attrs); + init(); + } + + protected void inflateHierarchy(Context context, @Nullable AttributeSet attrs) { + Resources resources = context.getResources(); + GenericDraweeHierarchyBuilder builder = + new GenericDraweeHierarchyBuilder(resources) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER); + GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs); + setAspectRatio(builder.getDesiredAspectRatio()); + setHierarchy(builder.build()); + } + + private void init() { + mZoomableController = createZoomableController(); + mZoomableController.setListener(mZoomableListener); + mTapGestureDetector = new GestureDetector(getContext(), mTapListenerWrapper); + } + + public void setIsDialtoneEnabled(boolean isDialtoneEnabled) { + mIsDialtoneEnabled = isDialtoneEnabled; + } + + /** + * Gets the original image bounds, in view-absolute coordinates. + * + *

The original image bounds are those reported by the hierarchy. The hierarchy itself may + * apply scaling on its own (e.g. due to scale type) so the reported bounds are not necessarily + * the same as the actual bitmap dimensions. In other words, the original image bounds correspond + * to the image bounds within this view when no zoomable transformation is applied, but including + * the potential scaling of the hierarchy. Having the actual bitmap dimensions abstracted away + * from this view greatly simplifies implementation because the actual bitmap may change (e.g. + * when a high-res image arrives and replaces the previously set low-res image). With proper + * hierarchy scaling (e.g. FIT_CENTER), this underlying change will not affect this view nor the + * zoomable transformation in any way. + */ + protected void getImageBounds(RectF outBounds) { + getHierarchy().getActualImageBounds(outBounds); + } + + /** + * Gets the bounds used to limit the translation, in view-absolute coordinates. + * + *

These bounds are passed to the zoomable controller in order to limit the translation. The + * image is attempted to be centered within the limit bounds if the transformed image is smaller. + * There will be no empty spaces within the limit bounds if the transformed image is bigger. This + * applies to each dimension (horizontal and vertical) independently. + * + *

Unless overridden by a subclass, these bounds are same as the view bounds. + */ + protected void getLimitBounds(RectF outBounds) { + outBounds.set(0, 0, getWidth(), getHeight()); + } + + /** + * Sets a custom zoomable controller, instead of using the default one. + */ + public void setZoomableController(ZoomableController zoomableController) { + Preconditions.checkNotNull(zoomableController); + mZoomableController.setListener(null); + mZoomableController = zoomableController; + mZoomableController.setListener(mZoomableListener); + } + + /** + * Gets the zoomable controller. + * + *

Zoomable controller can be used to zoom to point, or to map point from view to image + * coordinates for instance. + */ + public ZoomableController getZoomableController() { + return mZoomableController; + } + + /** + * Check whether the parent view can intercept touch events while zoomed. This can be used, for + * example, to swipe between images in a view pager while zoomed. + * + * @return true if touch events can be intercepted + */ + public boolean allowsTouchInterceptionWhileZoomed() { + return mAllowTouchInterceptionWhileZoomed; + } + + /** + * If this is set to true, parent views can intercept touch events while the view is zoomed. For + * example, this can be used to swipe between images in a view pager while zoomed. + * + * @param allowTouchInterceptionWhileZoomed true if the parent needs to intercept touches + */ + public void setAllowTouchInterceptionWhileZoomed(boolean allowTouchInterceptionWhileZoomed) { + mAllowTouchInterceptionWhileZoomed = allowTouchInterceptionWhileZoomed; + } + + /** + * Sets the tap listener. + */ + public void setTapListener(GestureDetector.SimpleOnGestureListener tapListener) { + mTapListenerWrapper.setListener(tapListener); + } + + /** + * Sets whether long-press tap detection is enabled. Unfortunately, long-press conflicts with + * onDoubleTapEvent. + */ + public void setIsLongpressEnabled(boolean enabled) { + mTapGestureDetector.setIsLongpressEnabled(enabled); + } + + public void setZoomingEnabled(boolean zoomingEnabled) { + mZoomingEnabled = zoomingEnabled; + mZoomableController.setEnabled(zoomingEnabled); + } + + /** + * Sets the image controller. + */ + @Override + public void setController(@Nullable DraweeController controller) { + setControllers(controller, null); + } + + /** + * Sets the controllers for the normal and huge image. + * + *

The huge image controller is used after the image gets scaled above a certain threshold. + * + *

IMPORTANT: in order to avoid a flicker when switching to the huge image, the huge image + * controller should have the normal-image-uri set as its low-res-uri. + * + * @param controller controller to be initially used + * @param hugeImageController controller to be used after the client starts zooming-in + */ + public void setControllers( + @Nullable DraweeController controller, @Nullable DraweeController hugeImageController) { + setControllersInternal(null, null); + mZoomableController.setEnabled(false); + setControllersInternal(controller, hugeImageController); + } + + private void setControllersInternal( + @Nullable DraweeController controller, @Nullable DraweeController hugeImageController) { + removeControllerListener(getController()); + addControllerListener(controller); + mHugeImageController = hugeImageController; + super.setController(controller); + } + + private void maybeSetHugeImageController() { + if (mHugeImageController != null + && mZoomableController.getScaleFactor() > HUGE_IMAGE_SCALE_FACTOR_THRESHOLD) { + setControllersInternal(mHugeImageController, null); + } + } + + private void removeControllerListener(DraweeController controller) { + if (controller instanceof AbstractDraweeController) { + ((AbstractDraweeController) controller).removeControllerListener(mControllerListener); + } + } + + private void addControllerListener(DraweeController controller) { + if (controller instanceof AbstractDraweeController) { + ((AbstractDraweeController) controller).addControllerListener(mControllerListener); + } + } + + @Override + protected void onDraw(Canvas canvas) { + int saveCount = canvas.save(); + canvas.concat(mZoomableController.getTransform()); + try { + super.onDraw(canvas); + } catch (Exception e) { + DraweeController controller = getController(); + if (controller != null && controller instanceof AbstractDraweeController) { + Object callerContext = ((AbstractDraweeController) controller).getCallerContext(); + if (callerContext != null) { + throw new RuntimeException( + String.format("Exception in onDraw, callerContext=%s", callerContext.toString()), e); + } + } + throw e; + } + canvas.restoreToCount(saveCount); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + int a = event.getActionMasked(); + FLog.v(getLogTag(), "onTouchEvent: %d, view %x, received", a, this.hashCode()); + if (!mIsDialtoneEnabled && mTapGestureDetector.onTouchEvent(event)) { + FLog.v(getLogTag(), + "onTouchEvent: %d, view %x, handled by tap gesture detector", + a, + this.hashCode()); + return true; + } + + if (!mIsDialtoneEnabled && mZoomableController.onTouchEvent(event)) { + FLog.v( + getLogTag(), + "onTouchEvent: %d, view %x, handled by zoomable controller", + a, + this.hashCode()); + if (!mAllowTouchInterceptionWhileZoomed && !mZoomableController.isIdentity()) { + final ViewParent parent = getParent(); + parent.requestDisallowInterceptTouchEvent(true); + } + return true; + } + if (super.onTouchEvent(event)) { + FLog.v(getLogTag(), "onTouchEvent: %d, view %x, handled by the super", a, this.hashCode()); + return true; + } + // None of our components reported that they handled the touch event. Upon returning false + // from this method, our parent won't send us any more events for this gesture. Unfortunately, + // some components may have started a delayed action, such as a long-press timer, and since we + // won't receive an ACTION_UP that would cancel that timer, a false event may be triggered. + // To prevent that we explicitly send one last cancel event when returning false. + MotionEvent cancelEvent = MotionEvent.obtain(event); + cancelEvent.setAction(MotionEvent.ACTION_CANCEL); + mTapGestureDetector.onTouchEvent(cancelEvent); + mZoomableController.onTouchEvent(cancelEvent); + cancelEvent.recycle(); + return false; + } + + @Override + public int computeHorizontalScrollRange() { + return mZoomableController.computeHorizontalScrollRange(); + } + + @Override + public int computeHorizontalScrollOffset() { + return mZoomableController.computeHorizontalScrollOffset(); + } + + @Override + public int computeHorizontalScrollExtent() { + return mZoomableController.computeHorizontalScrollExtent(); + } + + @Override + public int computeVerticalScrollRange() { + return mZoomableController.computeVerticalScrollRange(); + } + + @Override + public int computeVerticalScrollOffset() { + return mZoomableController.computeVerticalScrollOffset(); + } + + @Override + public int computeVerticalScrollExtent() { + return mZoomableController.computeVerticalScrollExtent(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + FLog.v(getLogTag(), "onLayout: view %x", this.hashCode()); + super.onLayout(changed, left, top, right, bottom); + updateZoomableControllerBounds(); + } + + private void onFinalImageSet() { + FLog.v(getLogTag(), "onFinalImageSet: view %x", this.hashCode()); + if (!mZoomableController.isEnabled() && mZoomingEnabled) { + mZoomableController.setEnabled(true); + updateZoomableControllerBounds(); + } + } + + private void onRelease() { + FLog.v(getLogTag(), "onRelease: view %x", this.hashCode()); + mZoomableController.setEnabled(false); + } + + protected void onTransformBegin(final Matrix transform) {} + + protected void onTransformChanged(Matrix transform) { + FLog.v(getLogTag(), "onTransformChanged: view %x, transform: %s", this.hashCode(), transform); + maybeSetHugeImageController(); + invalidate(); + } + + protected void onTransformEnd(final Matrix transform) {} + + protected void onTranslationLimited(final float offsetLeft, final float offsetTop) {} + + protected void updateZoomableControllerBounds() { + getImageBounds(mImageBounds); + getLimitBounds(mViewBounds); + // Log.d(TAG.getSimpleName(), "updateZoomableControllerBounds: mImageBounds: " + mImageBounds); + mZoomableController.setImageBounds(mImageBounds); + mZoomableController.setViewBounds(mViewBounds); + FLog.v(getLogTag(), + "updateZoomableControllerBounds: view %x, view bounds: %s, image bounds: %s", + this.hashCode(), + mViewBounds, + mImageBounds); + } + + protected Class getLogTag() { + return TAG; + } + + protected ZoomableController createZoomableController() { + return AnimatedZoomableController.newInstance(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/Emoji.java b/app/src/main/java/awais/instagrabber/customviews/emoji/Emoji.java new file mode 100644 index 0000000..2be01c4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/Emoji.java @@ -0,0 +1,67 @@ +package awais.instagrabber.customviews.emoji; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.Objects; + +public class Emoji { + private final String unicode; + private final String name; + private final List variants; + private GoogleCompatEmojiDrawable drawable; + + public Emoji(final String unicode, + final String name, + final List variants) { + this.unicode = unicode; + this.name = name; + this.variants = variants; + } + + public String getUnicode() { + return unicode; + } + + public void addVariant(final Emoji emoji) { + variants.add(emoji); + } + + public String getName() { + return name; + } + + public List getVariants() { + return variants; + } + + public GoogleCompatEmojiDrawable getDrawable() { + if (drawable == null && unicode != null) { + drawable = new GoogleCompatEmojiDrawable(unicode); + } + return drawable; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Emoji emoji = (Emoji) o; + return Objects.equals(unicode, emoji.unicode); + } + + @Override + public int hashCode() { + return Objects.hash(unicode); + } + + @NonNull + @Override + public String toString() { + return "Emoji{" + + "unicode='" + unicode + '\'' + + ", name='" + name + '\'' + + ", variants=" + variants + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiBottomSheetDialog.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiBottomSheetDialog.java new file mode 100644 index 0000000..340afc5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiBottomSheetDialog.java @@ -0,0 +1,102 @@ +package awais.instagrabber.customviews.emoji; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import awais.instagrabber.R; +import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.utils.emoji.EmojiParser; + +public class EmojiBottomSheetDialog extends BottomSheetDialogFragment { + public static final String TAG = EmojiBottomSheetDialog.class.getSimpleName(); + + private RecyclerView grid; + private EmojiPicker.OnEmojiClickListener callback; + + @NonNull + public static EmojiBottomSheetDialog newInstance() { + // Bundle args = new Bundle(); + // fragment.setArguments(args); + return new EmojiBottomSheetDialog(); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NORMAL, R.style.ThemeOverlay_Rounded_BottomSheetDialog); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + final Context context = getContext(); + if (context == null) return null; + grid = new RecyclerView(context); + return grid; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(); + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; + final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + if (bottomSheetInternal == null) return; + bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + bottomSheetInternal.requestLayout(); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + final Fragment parentFragment = getParentFragment(); + if (parentFragment instanceof EmojiPicker.OnEmojiClickListener) { + callback = (EmojiPicker.OnEmojiClickListener) parentFragment; + } + } + + @Override + public void onDestroyView() { + grid = null; + super.onDestroyView(); + } + + private void init() { + final Context context = getContext(); + if (context == null) return; + final GridLayoutManager gridLayoutManager = new GridLayoutManager(context, 9); + grid.setLayoutManager(gridLayoutManager); + grid.setHasFixedSize(true); + grid.setClipToPadding(false); + grid.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(8))); + final EmojiParser emojiParser = EmojiParser.Companion.getInstance(context); + final EmojiGridAdapter adapter = new EmojiGridAdapter(emojiParser, null, (view, emoji) -> { + if (callback != null) { + callback.onClick(view, emoji); + } + dismiss(); + }, null); + grid.setAdapter(adapter); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategory.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategory.java new file mode 100644 index 0000000..89a2c86 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategory.java @@ -0,0 +1,86 @@ +package awais.instagrabber.customviews.emoji; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +import java.util.Map; +import java.util.Objects; + +import awais.instagrabber.R; + +public class EmojiCategory { + private final EmojiCategoryType type; + private final Map emojis; + @DrawableRes + private int drawableRes; + + public EmojiCategory(final EmojiCategoryType type, final Map emojis) { + this.type = type; + this.emojis = emojis; + } + + public EmojiCategoryType getType() { + return type; + } + + public Map getEmojis() { + return emojis; + } + + public int getDrawableRes() { + if (drawableRes == 0) { + switch (type) { + case SMILEYS_AND_EMOTION: + drawableRes = R.drawable.ic_round_emoji_emotions_24; + break; + case ANIMALS_AND_NATURE: + drawableRes = R.drawable.ic_round_emoji_nature_24; + break; + case FOOD_AND_DRINK: + drawableRes = R.drawable.ic_round_emoji_food_beverage_24; + break; + case TRAVEL_AND_PLACES: + drawableRes = R.drawable.ic_round_emoji_transportation_24; + break; + case ACTIVITIES: + drawableRes = R.drawable.ic_round_emoji_events_24; + break; + case OBJECTS: + drawableRes = R.drawable.ic_round_emoji_objects_24; + break; + case SYMBOLS: + drawableRes = R.drawable.ic_round_emoji_symbols_24; + break; + case FLAGS: + drawableRes = R.drawable.ic_round_emoji_flags_24; + break; + case OTHERS: + drawableRes = R.drawable.ic_round_unknown_24; + break; + } + } + return drawableRes; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final EmojiCategory that = (EmojiCategory) o; + return type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(type); + } + + @NonNull + @Override + public String toString() { + return "EmojiCategory{" + + "type=" + type + + ", emojis=" + emojis + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryPageViewHolder.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryPageViewHolder.java new file mode 100644 index 0000000..8af0cb5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryPageViewHolder.java @@ -0,0 +1,47 @@ +package awais.instagrabber.customviews.emoji; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; +import awais.instagrabber.utils.emoji.EmojiParser; + +public class EmojiCategoryPageViewHolder extends RecyclerView.ViewHolder { + // private static final String TAG = EmojiCategoryPageViewHolder.class.getSimpleName(); + + private final View rootView; + private final OnEmojiClickListener onEmojiClickListener; + private final EmojiParser emojiParser = EmojiParser.Companion.getInstance(itemView.getContext()); + + public EmojiCategoryPageViewHolder(@NonNull final View rootView, + @NonNull final RecyclerView itemView, + final OnEmojiClickListener onEmojiClickListener) { + super(itemView); + this.rootView = rootView; + this.onEmojiClickListener = onEmojiClickListener; + } + + public void bind(final EmojiCategory emojiCategory) { + final RecyclerView emojiGrid = (RecyclerView) itemView; + final EmojiGridAdapter adapter = new EmojiGridAdapter( + emojiParser, + emojiCategory.getType(), + onEmojiClickListener, + (position, view, parent) -> { + final EmojiVariantPopup emojiVariantPopup = new EmojiVariantPopup(rootView, ((view1, emoji) -> { + if (onEmojiClickListener != null) { + onEmojiClickListener.onClick(view1, emoji); + } + final EmojiGridAdapter emojiGridAdapter = (EmojiGridAdapter) emojiGrid.getAdapter(); + if (emojiGridAdapter == null) return; + emojiGridAdapter.notifyItemChanged(position); + })); + emojiVariantPopup.show(view, parent); + return true; + } + ); + emojiGrid.setAdapter(adapter); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryType.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryType.java new file mode 100644 index 0000000..2bb4536 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryType.java @@ -0,0 +1,43 @@ +package awais.instagrabber.customviews.emoji; + +import java.util.HashMap; +import java.util.Map; + +public enum EmojiCategoryType { + SMILEYS_AND_EMOTION("Smileys & Emotion"), + // PEOPLE_AND_BODY("People & Body"), + ANIMALS_AND_NATURE("Animals & Nature"), + FOOD_AND_DRINK("Food & Drink"), + TRAVEL_AND_PLACES("Travel & Places"), + ACTIVITIES("Activities"), + OBJECTS("Objects"), + SYMBOLS("Symbols"), + FLAGS("Flags"), + OTHERS("Others"); + + private final String name; + + private static final Map map = new HashMap<>(); + + static { + for (EmojiCategoryType type : EmojiCategoryType.values()) { + map.put(type.name, type); + } + } + + EmojiCategoryType(final String name) { + this.name = name; + } + + public static EmojiCategoryType valueOfName(final String name) { + final EmojiCategoryType emojiCategoryType = map.get(name); + if (emojiCategoryType == null) { + return EmojiCategoryType.OTHERS; + } + return emojiCategoryType; + } + + public String getName() { + return name; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiGridAdapter.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiGridAdapter.java new file mode 100644 index 0000000..02e9636 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiGridAdapter.java @@ -0,0 +1,148 @@ +package awais.instagrabber.customviews.emoji; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; +import awais.instagrabber.databinding.ItemEmojiGridBinding; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.emoji.EmojiParser; + +public class EmojiGridAdapter extends RecyclerView.Adapter { + private static final String TAG = EmojiGridAdapter.class.getSimpleName(); + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final Emoji oldItem, @NonNull final Emoji newItem) { + return oldItem.equals(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull final Emoji oldItem, @NonNull final Emoji newItem) { + return oldItem.equals(newItem); + } + }; + + private final AsyncListDiffer differ; + private final OnEmojiLongClickListener onEmojiLongClickListener; + private final OnEmojiClickListener onEmojiClickListener; + private final EmojiVariantManager emojiVariantManager; + private final AppExecutors appExecutors; + + public EmojiGridAdapter(@NonNull final EmojiParser emojiParser, + final EmojiCategoryType emojiCategoryType, + final OnEmojiClickListener onEmojiClickListener, + final OnEmojiLongClickListener onEmojiLongClickListener) { + this.onEmojiClickListener = onEmojiClickListener; + this.onEmojiLongClickListener = onEmojiLongClickListener; + differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(diffCallback).build()); + final Map categoryMap = emojiParser.getCategoryMap(); + emojiVariantManager = EmojiVariantManager.getInstance(); + appExecutors = AppExecutors.INSTANCE; + setHasStableIds(true); + if (emojiCategoryType == null) { + // show all if type is null + differ.submitList(ImmutableList.copyOf(emojiParser.getAllEmojis().values())); + return; + } + final EmojiCategory emojiCategory = categoryMap.get(emojiCategoryType); + if (emojiCategory == null) { + differ.submitList(Collections.emptyList()); + return; + } + differ.submitList(ImmutableList.copyOf(emojiCategory.getEmojis().values())); + } + + @NonNull + @Override + public EmojiViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final ItemEmojiGridBinding binding = ItemEmojiGridBinding.inflate(layoutInflater, parent, false); + return new EmojiViewHolder(binding, onEmojiClickListener, onEmojiLongClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final EmojiViewHolder holder, final int position) { + final Emoji emoji = differ.getCurrentList().get(position); + final String variant = emojiVariantManager.getVariant(emoji.getUnicode()); + if (variant != null) { + appExecutors.getTasksThread().execute(() -> { + final Optional first = emoji.getVariants() + .stream() + .filter(e -> e.getUnicode().equals(variant)) + .findFirst(); + if (!first.isPresent()) return; + appExecutors.getMainThread().execute(() -> holder.bind(position, first.get(), emoji)); + }); + return; + } + holder.bind(position, emoji, emoji); + } + + @Override + public long getItemId(final int position) { + return differ.getCurrentList().get(position).hashCode(); + } + + @Override + public int getItemViewType(final int position) { + return 0; + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + public static class EmojiViewHolder extends RecyclerView.ViewHolder { + // private final AppExecutors appExecutors = AppExecutors.getInstance(); + private final ItemEmojiGridBinding binding; + private final OnEmojiClickListener onEmojiClickListener; + private final OnEmojiLongClickListener onEmojiLongClickListener; + + public EmojiViewHolder(@NonNull final ItemEmojiGridBinding binding, + final OnEmojiClickListener onEmojiClickListener, + final OnEmojiLongClickListener onEmojiLongClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onEmojiClickListener = onEmojiClickListener; + this.onEmojiLongClickListener = onEmojiLongClickListener; + } + + public void bind(final int position, final Emoji emoji, final Emoji parent) { + binding.image.setImageDrawable(null); + binding.indicator.setVisibility(View.GONE); + itemView.setOnLongClickListener(null); + // itemView.post(() -> { + binding.image.setImageDrawable(emoji.getDrawable()); + final boolean hasVariants = !parent.getVariants().isEmpty(); + binding.indicator.setVisibility(hasVariants ? View.VISIBLE : View.GONE); + if (onEmojiClickListener != null) { + itemView.setOnClickListener(v -> onEmojiClickListener.onClick(v, emoji)); + } + if (hasVariants && onEmojiLongClickListener != null) { + itemView.setOnLongClickListener(v -> onEmojiLongClickListener.onLongClick(position, v, parent)); + } + // }); + } + } + + public interface OnEmojiLongClickListener { + boolean onLongClick(int position, View view, Emoji emoji); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPicker.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPicker.java new file mode 100644 index 0000000..9621c24 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPicker.java @@ -0,0 +1,120 @@ +package awais.instagrabber.customviews.emoji; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.ContextCompat; +import androidx.core.widget.ImageViewCompat; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import java.util.Collection; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.utils.emoji.EmojiParser; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; + +public class EmojiPicker extends LinearLayout { + // private static final String TAG = EmojiPicker.class.getSimpleName(); + + public EmojiPicker(final Context context) { + super(context); + setup(); + } + + public EmojiPicker(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + setup(); + } + + public EmojiPicker(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + setup(); + } + + private void setup() { + setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + setOrientation(VERTICAL); + } + + public void init(@NonNull final View rootView, + final OnEmojiClickListener onEmojiClickListener, + final OnBackspaceClickListener onBackspaceClickListener) { + final TabLayout tabLayout = new TabLayout(getContext()); + final LayoutParams tabLayoutLayoutParam = new LayoutParams(MATCH_PARENT, WRAP_CONTENT); + tabLayout.setLayoutParams(tabLayoutLayoutParam); + tabLayout.setSelectedTabIndicatorGravity(TabLayout.INDICATOR_GRAVITY_TOP); + // tabLayout.setSelectedTabIndicatorColor(Utils.getThemeAccentColor(getContext())); + tabLayout.setSelectedTabIndicatorColor(getResources().getColor(R.color.blue_500)); + + final ViewPager2 viewPager2 = new ViewPager2(getContext()); + final LayoutParams viewPagerLayoutParam = new LayoutParams(MATCH_PARENT, 0); + viewPagerLayoutParam.weight = 1; + viewPager2.setLayoutParams(viewPagerLayoutParam); + viewPager2.setAdapter(new EmojiPickerPageAdapter(rootView, onEmojiClickListener)); + viewPager2.setOffscreenPageLimit(1); + + final Context context = getContext(); + if (context == null) return; + final EmojiParser emojiParser = EmojiParser.Companion.getInstance(context); + final List categories = emojiParser.getEmojiCategories(); + + new TabLayoutMediator(tabLayout, viewPager2, (tab, position) -> { + tab.view.setPadding(0, 0, 0, 0); + final EmojiCategory emojiCategory = categories.get(position); + if (emojiCategory == null) return; + final Collection emojis = emojiCategory.getEmojis().values(); + if (emojis.isEmpty()) return; + final AppCompatImageView imageView = getImageView(); + imageView.setImageResource(emojiCategory.getDrawableRes()); + tab.setCustomView(imageView); + }).attach(); + + final TabLayout.Tab tab = tabLayout.newTab(); + tab.view.setPadding(0, 0, 0, 0); + final AppCompatImageView imageView = getImageView(); + imageView.setImageResource(R.drawable.ic_round_backspace_24); + final TypedValue outValue = new TypedValue(); + getContext().getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); + imageView.setBackgroundResource(outValue.resourceId); + imageView.setOnClickListener(v -> { + if (onBackspaceClickListener == null) return; + onBackspaceClickListener.onClick(); + }); + tab.setCustomView(imageView); + tab.view.setEnabled(false); + tabLayout.addTab(tab); + addView(viewPager2); + addView(tabLayout); + } + + @NonNull + private AppCompatImageView getImageView() { + final AppCompatImageView imageView = new AppCompatImageView(getContext()); + imageView.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + imageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + ImageViewCompat.setImageTintList(imageView, ContextCompat.getColorStateList(getContext(), R.color.emoji_picker_tab_color)); + return imageView; + } + + public interface OnEmojiClickListener { + void onClick(View view, Emoji emoji); + } + + public interface OnBackspaceClickListener { + void onClick(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPickerPageAdapter.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPickerPageAdapter.java new file mode 100644 index 0000000..7fe330b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPickerPageAdapter.java @@ -0,0 +1,79 @@ +package awais.instagrabber.customviews.emoji; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; +import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.utils.emoji.EmojiParser; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; + +public class EmojiPickerPageAdapter extends RecyclerView.Adapter { + + private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final EmojiCategory oldItem, @NonNull final EmojiCategory newItem) { + return oldItem.equals(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull final EmojiCategory oldItem, @NonNull final EmojiCategory newItem) { + return oldItem.equals(newItem); + } + }; + + private final View rootView; + private final OnEmojiClickListener onEmojiClickListener; + private final AsyncListDiffer differ; + + public EmojiPickerPageAdapter(@NonNull final View rootView, + final OnEmojiClickListener onEmojiClickListener) { + this.rootView = rootView; + this.onEmojiClickListener = onEmojiClickListener; + differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(diffCallback).build()); + final EmojiParser emojiParser = EmojiParser.Companion.getInstance(rootView.getContext()); + differ.submitList(emojiParser.getEmojiCategories()); + setHasStableIds(true); + } + + @NonNull + @Override + public EmojiCategoryPageViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final Context context = parent.getContext(); + final RecyclerView emojiGrid = new RecyclerView(context); + emojiGrid.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + emojiGrid.setLayoutManager(new GridLayoutManager(context, 9)); + emojiGrid.setHasFixedSize(true); + emojiGrid.setClipToPadding(false); + emojiGrid.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(8))); + return new EmojiCategoryPageViewHolder(rootView, emojiGrid, onEmojiClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final EmojiCategoryPageViewHolder holder, final int position) { + final EmojiCategory emojiCategory = differ.getCurrentList().get(position); + holder.bind(emojiCategory); + } + + @Override + public long getItemId(final int position) { + return differ.getCurrentList().get(position).hashCode(); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantManager.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantManager.java new file mode 100644 index 0000000..28a39d2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantManager.java @@ -0,0 +1,66 @@ +package awais.instagrabber.customviews.emoji; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static awais.instagrabber.utils.Constants.PREF_EMOJI_VARIANTS; + +public class EmojiVariantManager { + private static final String TAG = EmojiVariantManager.class.getSimpleName(); + private static final Object LOCK = new Object(); + + private final AppExecutors appExecutors = AppExecutors.INSTANCE; + private final Map selectedVariantMap = new HashMap<>(); + + private static EmojiVariantManager instance; + + public static EmojiVariantManager getInstance() { + if (instance == null) { + synchronized (LOCK) { + if (instance == null) { + instance = new EmojiVariantManager(); + } + } + } + return instance; + } + + private EmojiVariantManager() { + final String variantsJson = Utils.settingsHelper.getString(PREF_EMOJI_VARIANTS); + if (TextUtils.isEmpty(variantsJson)) return; + try { + final JSONObject variantsJSONObject = new JSONObject(variantsJson); + final Iterator keys = variantsJSONObject.keys(); + keys.forEachRemaining(s -> selectedVariantMap.put(s, variantsJSONObject.optString(s))); + } catch (JSONException e) { + Log.e(TAG, "EmojiVariantManager: ", e); + } + } + + @Nullable + public String getVariant(final String parentUnicode) { + return selectedVariantMap.get(parentUnicode); + } + + public void setVariant(final String parent, final String variant) { + if (parent == null || variant == null) return; + selectedVariantMap.put(parent, variant); + appExecutors.getTasksThread().execute(() -> { + final JSONObject jsonObject = new JSONObject(selectedVariantMap); + final String json = jsonObject.toString(); + Utils.settingsHelper.putString(PREF_EMOJI_VARIANTS, json); + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantPopup.java b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantPopup.java new file mode 100644 index 0000000..0d18625 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantPopup.java @@ -0,0 +1,159 @@ +package awais.instagrabber.customviews.emoji; + +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Point; +import android.graphics.drawable.BitmapDrawable; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; + +import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; +import awais.instagrabber.databinding.ItemEmojiGridBinding; +import awais.instagrabber.databinding.LayoutEmojiVariantPopupBinding; +import awais.instagrabber.utils.AppExecutors; + +import static android.view.View.MeasureSpec.makeMeasureSpec; + +public final class EmojiVariantPopup { + private static final int DO_NOT_UPDATE_FLAG = -1; + + private final View rootView; + private final OnEmojiClickListener listener; + + private PopupWindow popupWindow; + private View rootImageView; + private final EmojiVariantManager emojiVariantManager; + private final AppExecutors appExecutors; + + public EmojiVariantPopup(@NonNull final View rootView, + final OnEmojiClickListener listener) { + this.rootView = rootView; + this.listener = listener; + emojiVariantManager = EmojiVariantManager.getInstance(); + appExecutors = AppExecutors.INSTANCE; + } + + public void show(@NonNull final View view, @NonNull final Emoji emoji) { + dismiss(); + + rootImageView = view; + + final View content = initView(view.getContext(), emoji, view.getWidth()); + + popupWindow = new PopupWindow(content, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT); + popupWindow.setFocusable(true); + popupWindow.setOutsideTouchable(true); + popupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); + popupWindow.setBackgroundDrawable(new BitmapDrawable(view.getContext().getResources(), (Bitmap) null)); + + content.measure(makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + + final Point location = locationOnScreen(view); + final Point desiredLocation = new Point( + location.x - content.getMeasuredWidth() / 2 + view.getWidth() / 2, + location.y - content.getMeasuredHeight() + ); + + popupWindow.showAtLocation(rootView, Gravity.NO_GRAVITY, desiredLocation.x, desiredLocation.y); + rootImageView.getParent().requestDisallowInterceptTouchEvent(true); + fixPopupLocation(popupWindow, desiredLocation); + } + + public void dismiss() { + rootImageView = null; + + if (popupWindow != null) { + popupWindow.dismiss(); + popupWindow = null; + } + } + + private View initView(@NonNull final Context context, @NonNull final Emoji emoji, final int width) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final LayoutEmojiVariantPopupBinding binding = LayoutEmojiVariantPopupBinding.inflate(layoutInflater, null, false); + final List variants = new ArrayList<>(emoji.getVariants()); + // Add parent at start of list + // variants.add(0, emoji); + for (final Emoji variant : variants) { + final ItemEmojiGridBinding itemBinding = ItemEmojiGridBinding.inflate(layoutInflater, binding.container, false); + final ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) itemBinding.image.getLayoutParams(); + // Use the same size for Emojis as in the picker. + layoutParams.width = width; + itemBinding.image.setImageDrawable(variant.getDrawable()); + itemBinding.image.setOnClickListener(view -> { + if (listener != null) { + if (!variant.getUnicode().equals(emojiVariantManager.getVariant(emoji.getUnicode()))) { + emojiVariantManager.setVariant(emoji.getUnicode(), variant.getUnicode()); + } + listener.onClick(view, variant); + } + dismiss(); + }); + binding.container.addView(itemBinding.getRoot()); + } + return binding.getRoot(); + } + + @NonNull + private Point locationOnScreen(@NonNull final View view) { + final int[] location = new int[2]; + view.getLocationOnScreen(location); + return new Point(location[0], location[1]); + } + + private void fixPopupLocation(@NonNull final PopupWindow popupWindow, @NonNull final Point desiredLocation) { + popupWindow.getContentView().post(() -> { + final Point actualLocation = locationOnScreen(popupWindow.getContentView()); + + if (!(actualLocation.x == desiredLocation.x && actualLocation.y == desiredLocation.y)) { + final int differenceX = actualLocation.x - desiredLocation.x; + final int differenceY = actualLocation.y - desiredLocation.y; + + final int fixedOffsetX; + final int fixedOffsetY; + + if (actualLocation.x > desiredLocation.x) { + fixedOffsetX = desiredLocation.x - differenceX; + } else { + fixedOffsetX = desiredLocation.x + differenceX; + } + + if (actualLocation.y > desiredLocation.y) { + fixedOffsetY = desiredLocation.y - differenceY; + } else { + fixedOffsetY = desiredLocation.y + differenceY; + } + + popupWindow.update(fixedOffsetX, fixedOffsetY, DO_NOT_UPDATE_FLAG, DO_NOT_UPDATE_FLAG); + } + }); + } +} + diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/GoogleCompatEmojiDrawable.java b/app/src/main/java/awais/instagrabber/customviews/emoji/GoogleCompatEmojiDrawable.java new file mode 100644 index 0000000..ae1aff0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/GoogleCompatEmojiDrawable.java @@ -0,0 +1,98 @@ +package awais.instagrabber.customviews.emoji; + +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.Spanned; +import android.text.TextPaint; + +import androidx.annotation.NonNull; +import androidx.emoji.text.EmojiCompat; +import androidx.emoji.text.EmojiSpan; + +/** + * An emoji drawable backed by a span generated by the Google emoji support library. + */ +final class GoogleCompatEmojiDrawable extends Drawable { + private static final String TAG = GoogleCompatEmojiDrawable.class.getSimpleName(); + private static final float TEXT_SIZE_FACTOR = 0.8f; + private static final float BASELINE_OFFSET_FACTOR = 0.225f; + + private EmojiSpan emojiSpan; + private boolean processed; + private CharSequence emojiCharSequence; + private final TextPaint textPaint = new TextPaint(); + + GoogleCompatEmojiDrawable(@NonNull final String unicode) { + emojiCharSequence = unicode; + textPaint.setStyle(Paint.Style.FILL); + textPaint.setColor(0x0ffffffff); + textPaint.setAntiAlias(true); + } + + private void process() { + emojiCharSequence = EmojiCompat.get().process(emojiCharSequence); + if (emojiCharSequence instanceof Spanned) { + final Object[] spans = ((Spanned) emojiCharSequence).getSpans(0, emojiCharSequence.length(), EmojiSpan.class); + if (spans.length > 0) { + emojiSpan = (EmojiSpan) spans[0]; + } + } + } + + @Override + public void draw(@NonNull final Canvas canvas) { + final Rect bounds = getBounds(); + textPaint.setTextSize(bounds.height() * TEXT_SIZE_FACTOR); + final int y = Math.round(bounds.bottom - bounds.height() * BASELINE_OFFSET_FACTOR); + + if (!processed && EmojiCompat.get().getLoadState() != EmojiCompat.LOAD_STATE_LOADING) { + processed = true; + if (EmojiCompat.get().getLoadState() != EmojiCompat.LOAD_STATE_FAILED) { + process(); + } + } + + if (emojiSpan == null) { + canvas.drawText(emojiCharSequence, 0, emojiCharSequence.length(), bounds.left, y, textPaint); + } else { + emojiSpan.draw(canvas, emojiCharSequence, 0, emojiCharSequence.length(), bounds.left, bounds.top, y, bounds.bottom, textPaint); + } + } + + @Override + public void setAlpha(final int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(final ColorFilter colorFilter) { + textPaint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return PixelFormat.UNKNOWN; + } +} + diff --git a/app/src/main/java/awais/instagrabber/customviews/emoji/ReactionsManager.java b/app/src/main/java/awais/instagrabber/customviews/emoji/ReactionsManager.java new file mode 100644 index 0000000..d75ba8c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/emoji/ReactionsManager.java @@ -0,0 +1,78 @@ +package awais.instagrabber.customviews.emoji; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableList; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.utils.emoji.EmojiParser; + +import static awais.instagrabber.utils.Constants.PREF_REACTIONS; + +public class ReactionsManager { + private static final String TAG = ReactionsManager.class.getSimpleName(); + private static final Object LOCK = new Object(); + + // private final AppExecutors appExecutors = AppExecutors.INSTANCE; + private final List reactions = new ArrayList<>(); + + private static ReactionsManager instance; + + public static ReactionsManager getInstance(@NonNull final Context context) { + if (instance == null) { + synchronized (LOCK) { + if (instance == null) { + instance = new ReactionsManager(context); + } + } + } + return instance; + } + + private ReactionsManager(@NonNull final Context context) { + final EmojiParser emojiParser = EmojiParser.Companion.getInstance(context); + String reactionsJson = Utils.settingsHelper.getString(PREF_REACTIONS); + if (TextUtils.isEmpty(reactionsJson)) { + final ImmutableList list = ImmutableList.of("❤️", "\uD83D\uDE02", "\uD83D\uDE2E", "\uD83D\uDE22", "\uD83D\uDE21", "\uD83D\uDC4D"); + reactionsJson = new JSONArray(list).toString(); + } + final Map allEmojis = emojiParser.getAllEmojis(); + try { + final JSONArray reactionsJsonArray = new JSONArray(reactionsJson); + for (int i = 0; i < reactionsJsonArray.length(); i++) { + final String emojiUnicode = reactionsJsonArray.optString(i); + if (emojiUnicode == null) continue; + final Emoji emoji = allEmojis.get(emojiUnicode); + if (emoji == null) continue; + reactions.add(emoji); + } + } catch (JSONException e) { + Log.e(TAG, "ReactionsManager: ", e); + } + } + + public List getReactions() { + return reactions; + } + + // public void setVariant(final String parent, final String variant) { + // if (parent == null || variant == null) return; + // selectedVariantMap.put(parent, variant); + // appExecutors.tasksThread().execute(() -> { + // final JSONObject jsonObject = new JSONObject(selectedVariantMap); + // final String json = jsonObject.toString(); + // Utils.settingsHelper.putString(PREF_EMOJI_VARIANTS, json); + // }); + // } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/ChangeText.java b/app/src/main/java/awais/instagrabber/customviews/helpers/ChangeText.java new file mode 100644 index 0000000..bd3613e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/ChangeText.java @@ -0,0 +1,320 @@ +package awais.instagrabber.customviews.helpers; + +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ValueAnimator; +import android.graphics.Color; +import android.util.Log; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.transition.Transition; +import androidx.transition.TransitionListenerAdapter; +import androidx.transition.TransitionValues; + +import java.util.Map; +import java.util.Objects; + +import awais.instagrabber.BuildConfig; + +/** + * This transition tracks changes to the text in TextView targets. If the text + * changes between the start and end scenes, the transition ensures that the + * starting text stays until the transition ends, at which point it changes + * to the end text. This is useful in situations where you want to resize a + * text view to its new size before displaying the text that goes there. + */ +public class ChangeText extends Transition { + private static final String LOG_TAG = "TextChange"; + private static final String PROPNAME_TEXT = "android:textchange:text"; + private static final String PROPNAME_TEXT_SELECTION_START = + "android:textchange:textSelectionStart"; + private static final String PROPNAME_TEXT_SELECTION_END = + "android:textchange:textSelectionEnd"; + private static final String PROPNAME_TEXT_COLOR = "android:textchange:textColor"; + private int mChangeBehavior = CHANGE_BEHAVIOR_KEEP; + private boolean crossFade; + /** + * Flag specifying that the text in affected/changing TextView targets will keep + * their original text during the transition, setting it to the final text when + * the transition ends. This is the default behavior. + * + * @see #setChangeBehavior(int) + */ + public static final int CHANGE_BEHAVIOR_KEEP = 0; + /** + * Flag specifying that the text changing animation should first fade + * out the original text completely. The new text is set on the target + * view at the end of the fade-out animation. This transition is typically + * used with a later {@link #CHANGE_BEHAVIOR_IN} transition, allowing more + * flexibility than the {@link #CHANGE_BEHAVIOR_OUT_IN} by allowing other + * transitions to be run sequentially or in parallel with these fades. + * + * @see #setChangeBehavior(int) + */ + public static final int CHANGE_BEHAVIOR_OUT = 1; + /** + * Flag specifying that the text changing animation should fade in the + * end text into the affected target view(s). This transition is typically + * used in conjunction with an earlier {@link #CHANGE_BEHAVIOR_OUT} + * transition, possibly with other transitions running as well, such as + * a sequence to fade out, then resize the view, then fade in. + * + * @see #setChangeBehavior(int) + */ + public static final int CHANGE_BEHAVIOR_IN = 2; + /** + * Flag specifying that the text changing animation should first fade + * out the original text completely and then fade in the + * new text. + * + * @see #setChangeBehavior(int) + */ + public static final int CHANGE_BEHAVIOR_OUT_IN = 3; + private static final String[] sTransitionProperties = { + PROPNAME_TEXT, + PROPNAME_TEXT_SELECTION_START, + PROPNAME_TEXT_SELECTION_END + }; + + /** + * Sets the type of changing animation that will be run, one of + * {@link #CHANGE_BEHAVIOR_KEEP}, {@link #CHANGE_BEHAVIOR_OUT}, + * {@link #CHANGE_BEHAVIOR_IN}, and {@link #CHANGE_BEHAVIOR_OUT_IN}. + * + * @param changeBehavior The type of fading animation to use when this + * transition is run. + * @return this textChange object. + */ + public ChangeText setChangeBehavior(int changeBehavior) { + if (changeBehavior >= CHANGE_BEHAVIOR_KEEP && changeBehavior <= CHANGE_BEHAVIOR_OUT_IN) { + mChangeBehavior = changeBehavior; + } + return this; + } + + public ChangeText setCrossFade(final boolean crossFade) { + this.crossFade = crossFade; + return this; + } + + @Override + public String[] getTransitionProperties() { + return sTransitionProperties; + } + + /** + * Returns the type of changing animation that will be run. + * + * @return either {@link #CHANGE_BEHAVIOR_KEEP}, {@link #CHANGE_BEHAVIOR_OUT}, + * {@link #CHANGE_BEHAVIOR_IN}, or {@link #CHANGE_BEHAVIOR_OUT_IN}. + */ + public int getChangeBehavior() { + return mChangeBehavior; + } + + private void captureValues(TransitionValues transitionValues) { + if (transitionValues.view instanceof TextView) { + TextView textview = (TextView) transitionValues.view; + transitionValues.values.put(PROPNAME_TEXT, textview.getText()); + if (textview instanceof EditText) { + transitionValues.values.put(PROPNAME_TEXT_SELECTION_START, + textview.getSelectionStart()); + transitionValues.values.put(PROPNAME_TEXT_SELECTION_END, + textview.getSelectionEnd()); + } + if (mChangeBehavior > CHANGE_BEHAVIOR_KEEP) { + transitionValues.values.put(PROPNAME_TEXT_COLOR, textview.getCurrentTextColor()); + } + } + } + + @Override + public void captureStartValues(@NonNull TransitionValues transitionValues) { + captureValues(transitionValues); + } + + @Override + public void captureEndValues(@NonNull TransitionValues transitionValues) { + captureValues(transitionValues); + } + + @Override + public Animator createAnimator(@NonNull ViewGroup sceneRoot, TransitionValues startValues, + TransitionValues endValues) { + if (startValues == null || endValues == null || + !(startValues.view instanceof TextView) || !(endValues.view instanceof TextView)) { + return null; + } + final TextView view = (TextView) endValues.view; + Map startVals = startValues.values; + Map endVals = endValues.values; + final CharSequence startText = startVals.get(PROPNAME_TEXT) != null ? + (CharSequence) startVals.get(PROPNAME_TEXT) : ""; + final CharSequence endText = endVals.get(PROPNAME_TEXT) != null ? + (CharSequence) endVals.get(PROPNAME_TEXT) : ""; + final int startSelectionStart, startSelectionEnd, endSelectionStart, endSelectionEnd; + if (view instanceof EditText) { + startSelectionStart = startVals.get(PROPNAME_TEXT_SELECTION_START) != null ? + (Integer) startVals.get(PROPNAME_TEXT_SELECTION_START) : -1; + startSelectionEnd = startVals.get(PROPNAME_TEXT_SELECTION_END) != null ? + (Integer) startVals.get(PROPNAME_TEXT_SELECTION_END) : startSelectionStart; + endSelectionStart = endVals.get(PROPNAME_TEXT_SELECTION_START) != null ? + (Integer) endVals.get(PROPNAME_TEXT_SELECTION_START) : -1; + endSelectionEnd = endVals.get(PROPNAME_TEXT_SELECTION_END) != null ? + (Integer) endVals.get(PROPNAME_TEXT_SELECTION_END) : endSelectionStart; + } else { + startSelectionStart = startSelectionEnd = endSelectionStart = endSelectionEnd = -1; + } + if (!Objects.equals(startText, endText)) { + final int startColor; + final int endColor; + if (mChangeBehavior != CHANGE_BEHAVIOR_IN) { + view.setText(startText); + if (view instanceof EditText) { + setSelection(((EditText) view), startSelectionStart, startSelectionEnd); + } + } + Animator anim; + if (mChangeBehavior == CHANGE_BEHAVIOR_KEEP) { + startColor = endColor = 0; + anim = ValueAnimator.ofFloat(0, 1); + anim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + if (Objects.equals(startText, view.getText())) { + // Only set if it hasn't been changed since anim started + view.setText(endText); + if (view instanceof EditText) { + setSelection(((EditText) view), endSelectionStart, endSelectionEnd); + } + } + } + }); + } else { + startColor = (Integer) startVals.get(PROPNAME_TEXT_COLOR); + endColor = (Integer) endVals.get(PROPNAME_TEXT_COLOR); + // Fade out start text + ValueAnimator outAnim = null, inAnim = null; + if (mChangeBehavior == CHANGE_BEHAVIOR_OUT_IN || + mChangeBehavior == CHANGE_BEHAVIOR_OUT) { + outAnim = ValueAnimator.ofInt(Color.alpha(startColor), 0); + outAnim.addUpdateListener(animation -> { + int currAlpha = (Integer) animation.getAnimatedValue(); + view.setTextColor(currAlpha << 24 | startColor & 0xffffff); + }); + outAnim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + if (Objects.equals(startText, view.getText())) { + // Only set if it hasn't been changed since anim started + view.setText(endText); + if (view instanceof EditText) { + setSelection(((EditText) view), endSelectionStart, + endSelectionEnd); + } + } + // restore opaque alpha and correct end color + view.setTextColor(endColor); + } + }); + } + if (mChangeBehavior == CHANGE_BEHAVIOR_OUT_IN || + mChangeBehavior == CHANGE_BEHAVIOR_IN) { + inAnim = ValueAnimator.ofInt(0, Color.alpha(endColor)); + inAnim.addUpdateListener(animation -> { + int currAlpha = (Integer) animation.getAnimatedValue(); + view.setTextColor(currAlpha << 24 | endColor & 0xffffff); + }); + inAnim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(Animator animation) { + // restore opaque alpha and correct end color + view.setTextColor(endColor); + } + }); + } + if (outAnim != null && inAnim != null) { + anim = new AnimatorSet(); + final AnimatorSet animatorSet = (AnimatorSet) anim; + if (crossFade) { + animatorSet.playTogether(outAnim, inAnim); + } else { + animatorSet.playSequentially(outAnim, inAnim); + } + } else if (outAnim != null) { + anim = outAnim; + } else { + // Must be an in-only animation + anim = inAnim; + } + } + TransitionListener transitionListener = new TransitionListenerAdapter() { + int mPausedColor = 0; + + @Override + public void onTransitionPause(@NonNull Transition transition) { + if (mChangeBehavior != CHANGE_BEHAVIOR_IN) { + view.setText(endText); + if (view instanceof EditText) { + setSelection(((EditText) view), endSelectionStart, endSelectionEnd); + } + } + if (mChangeBehavior > CHANGE_BEHAVIOR_KEEP) { + mPausedColor = view.getCurrentTextColor(); + view.setTextColor(endColor); + } + } + + @Override + public void onTransitionResume(@NonNull Transition transition) { + if (mChangeBehavior != CHANGE_BEHAVIOR_IN) { + view.setText(startText); + if (view instanceof EditText) { + setSelection(((EditText) view), startSelectionStart, startSelectionEnd); + } + } + if (mChangeBehavior > CHANGE_BEHAVIOR_KEEP) { + view.setTextColor(mPausedColor); + } + } + + @Override + public void onTransitionEnd(Transition transition) { + transition.removeListener(this); + } + }; + addListener(transitionListener); + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "createAnimator returning " + anim); + } + return anim; + } + return null; + } + + private void setSelection(EditText editText, int start, int end) { + if (start >= 0 && end >= 0) { + editText.setSelection(start, end); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java new file mode 100644 index 0000000..e1fda46 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java @@ -0,0 +1,87 @@ +/* + * Copyright 2020 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 awais.instagrabber.customviews.helpers; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A [WindowInsetsAnimationCompat.Callback] which will request and clear focus on the given view, + * depending on the [WindowInsetsCompat.Type.ime] visibility state when an IME + * [WindowInsetsAnimationCompat] has finished. + *

+ * This is primarily used when animating the [WindowInsetsCompat.Type.ime], so that the + * appropriate view is focused for accepting input from the IME. + */ +public class ControlFocusInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + + private final View view; + + public ControlFocusInsetsAnimationCallback(@NonNull final View view) { + this(view, DISPATCH_MODE_STOP); + } + + /** + * @param view the view to request/clear focus + * @param dispatchMode The dispatch mode for this callback. + * @see WindowInsetsAnimationCompat.Callback.DispatchMode + */ + public ControlFocusInsetsAnimationCallback(@NonNull final View view, final int dispatchMode) { + super(dispatchMode); + this.view = view; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnimations) { + // no-op and return the insets + return insets; + } + + @Override + public void onEnd(final WindowInsetsAnimationCompat animation) { + if ((animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0) { + // The animation has now finished, so we can check the view's focus state. + // We post the check because the rootWindowInsets has not yet been updated, but will + // be in the next message traversal + view.post(this::checkFocus); + } + } + + private void checkFocus() { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + boolean imeVisible = false; + if (rootWindowInsets != null) { + imeVisible = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + } + if (imeVisible && view.getRootView().findFocus() == null) { + // If the IME will be visible, and there is not a currently focused view in + // the hierarchy, request focus on our view + view.requestFocus(); + } else if (!imeVisible && view.isFocused()) { + // If the IME will not be visible and our view is currently focused, clear the focus + view.clearFocus(); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/CustomHideBottomViewOnScrollBehavior.java b/app/src/main/java/awais/instagrabber/customviews/helpers/CustomHideBottomViewOnScrollBehavior.java new file mode 100644 index 0000000..dabc622 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/CustomHideBottomViewOnScrollBehavior.java @@ -0,0 +1,48 @@ +package awais.instagrabber.customviews.helpers; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.ViewCompat; + +import com.google.android.material.behavior.HideBottomViewOnScrollBehavior; +import com.google.android.material.bottomnavigation.BottomNavigationView; + +public class CustomHideBottomViewOnScrollBehavior extends HideBottomViewOnScrollBehavior { + private static final String TAG = "CustomHideBottomView"; + + public CustomHideBottomViewOnScrollBehavior() { + } + + public CustomHideBottomViewOnScrollBehavior(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onStartNestedScroll(@NonNull final CoordinatorLayout coordinatorLayout, + @NonNull final BottomNavigationView child, + @NonNull final View directTargetChild, + @NonNull final View target, + final int nestedScrollAxes, + final int type) { + return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL; + } + + @Override + public void onNestedPreScroll(@NonNull final CoordinatorLayout coordinatorLayout, + @NonNull final BottomNavigationView child, + @NonNull final View target, + final int dx, + final int dy, + @NonNull final int[] consumed, + final int type) { + if (dy > 0) { + slideDown(child); + } else if (dy < 0) { + slideUp(child); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java new file mode 100644 index 0000000..125b65c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java @@ -0,0 +1,117 @@ +package awais.instagrabber.customviews.helpers; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A customized {@link TranslateDeferringInsetsAnimationCallback} for the emoji picker + */ +public class EmojiPickerInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + private static final String TAG = EmojiPickerInsetsAnimationCallback.class.getSimpleName(); + + private final View view; + private final int persistentInsetTypes; + private final int deferredInsetTypes; + + private int kbHeight; + private onKbVisibilityChangeListener listener; + private boolean shouldTranslate; + + public EmojiPickerInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes) { + this(view, persistentInsetTypes, deferredInsetTypes, DISPATCH_MODE_STOP); + } + + public EmojiPickerInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes, + final int dispatchMode) { + super(dispatchMode); + if ((persistentInsetTypes & deferredInsetTypes) != 0) { + throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + + "any of same WindowInsetsCompat.Type values"); + } + this.view = view; + this.persistentInsetTypes = persistentInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnimations) { + // onProgress() is called when any of the running animations progress... + + // First we get the insets which are potentially deferred + final Insets typesInset = insets.getInsets(deferredInsetTypes); + // Then we get the persistent inset types which are applied as padding during layout + final Insets otherInset = insets.getInsets(persistentInsetTypes); + + // Now that we subtract the two insets, to calculate the difference. We also coerce + // the insets to be >= 0, to make sure we don't use negative insets. + final Insets subtract = Insets.subtract(typesInset, otherInset); + final Insets diff = Insets.max(subtract, Insets.NONE); + + // The resulting `diff` insets contain the values for us to apply as a translation + // to the view + view.setTranslationX(diff.left - diff.right); + view.setTranslationY(shouldTranslate ? diff.top - diff.bottom : -kbHeight); + + return insets; + } + + @Override + public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { + try { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (kbHeight == 0) { + if (rootWindowInsets == null) return; + final Insets imeInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()); + final Insets navBarInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()); + kbHeight = imeInsets.bottom - navBarInsets.bottom; + final ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (layoutParams != null) { + layoutParams.height = kbHeight; + layoutParams.setMargins(layoutParams.leftMargin, layoutParams.topMargin, layoutParams.rightMargin, -kbHeight); + } + } + view.setTranslationX(0f); + final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + float translationY = 0; + if (!shouldTranslate) { + translationY = -kbHeight; + if (visible) { + translationY = 0; + } + } + view.setTranslationY(translationY); + + if (listener != null && rootWindowInsets != null) { + listener.onChange(visible); + } + } finally { + shouldTranslate = true; + } + } + + public void setShouldTranslate(final boolean shouldTranslate) { + this.shouldTranslate = shouldTranslate; + } + + public void setKbVisibilityListener(final onKbVisibilityChangeListener listener) { + this.listener = listener; + } + + public interface onKbVisibilityChangeListener { + void onChange(boolean isVisible); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/GridAutofitLayoutManager.java b/app/src/main/java/awais/instagrabber/customviews/helpers/GridAutofitLayoutManager.java new file mode 100755 index 0000000..ed058d0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/GridAutofitLayoutManager.java @@ -0,0 +1,37 @@ +package awais.instagrabber.customviews.helpers; + +import android.content.Context; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.utils.Utils; + +public class GridAutofitLayoutManager extends GridLayoutManager { + private int mColumnWidth; + private boolean mColumnWidthChanged = true; + + public GridAutofitLayoutManager(Context context, int columnWidth) { + super(context, 1); + if (columnWidth <= 0) columnWidth = (int) (48 * Utils.displayMetrics.density); + if (columnWidth > 0 && columnWidth != mColumnWidth) { + mColumnWidth = columnWidth; + mColumnWidthChanged = true; + } + } + + @Override + public void onLayoutChildren(final RecyclerView.Recycler recycler, final RecyclerView.State state) { + final int width = getWidth(); + final int height = getHeight(); + if (mColumnWidthChanged && mColumnWidth > 0 && width > 0 && height > 0) { + final int totalSpace = getOrientation() == VERTICAL ? width - getPaddingRight() - getPaddingLeft() + : height - getPaddingTop() - getPaddingBottom(); + + setSpanCount(Math.max(1, Math.min(totalSpace / mColumnWidth, 3))); + + mColumnWidthChanged = false; + } + super.onLayoutChildren(recycler, state); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/GridSpacingItemDecoration.java b/app/src/main/java/awais/instagrabber/customviews/helpers/GridSpacingItemDecoration.java new file mode 100755 index 0000000..f34c30f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/GridSpacingItemDecoration.java @@ -0,0 +1,39 @@ +package awais.instagrabber.customviews.helpers; + +import android.graphics.Rect; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public class GridSpacingItemDecoration extends RecyclerView.ItemDecoration { + private final int halfSpace; + + private boolean hasHeader; + + public GridSpacingItemDecoration(int spacing) { + halfSpace = spacing / 2; + } + + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + if (hasHeader && parent.getChildAdapterPosition(view) == 0) { + outRect.bottom = halfSpace; + outRect.left = -halfSpace; + outRect.right = -halfSpace; + return; + } + if (parent.getPaddingLeft() != halfSpace) { + parent.setPadding(halfSpace, hasHeader ? 0 : halfSpace, halfSpace, halfSpace); + parent.setClipToPadding(false); + } + outRect.top = halfSpace; + outRect.bottom = halfSpace; + outRect.left = halfSpace; + outRect.right = halfSpace; + } + + public void setHasHeader(final boolean hasHeader) { + this.hasHeader = hasHeader; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/HeaderItemDecoration.java b/app/src/main/java/awais/instagrabber/customviews/helpers/HeaderItemDecoration.java new file mode 100644 index 0000000..35e9802 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/HeaderItemDecoration.java @@ -0,0 +1,199 @@ +package awais.instagrabber.customviews.helpers; + +import android.graphics.Canvas; +import android.graphics.Rect; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Pair; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Java implementation of this gist by filipkowicz + */ +public class HeaderItemDecoration extends RecyclerView.ItemDecoration { + private static final String TAG = HeaderItemDecoration.class.getSimpleName(); + + private final HeaderItemDecorationCallback callback; + + private boolean layoutReversed = false; + private Pair currentHeader; + + public HeaderItemDecoration(@NonNull RecyclerView parent, + @NonNull HeaderItemDecorationCallback callback) { + this.callback = callback; + final RecyclerView.LayoutManager layoutManager = parent.getLayoutManager(); + if (layoutManager instanceof LinearLayoutManager) { + layoutReversed = ((LinearLayoutManager) layoutManager).getReverseLayout(); + } + //noinspection rawtypes + final RecyclerView.Adapter adapter = parent.getAdapter(); + if (adapter == null) return; + adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + // clear saved header as it can be outdated now + Log.d(TAG, "registerAdapterDataObserver"); + currentHeader = null; + } + }); + parent.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + // clear saved layout as it may need layout update + Log.d(TAG, "addOnLayoutChangeListener"); + currentHeader = null; + }); + parent.addOnItemTouchListener(new RecyclerView.SimpleOnItemTouchListener() { + @Override + public boolean onInterceptTouchEvent(@NonNull final RecyclerView rv, @NonNull final MotionEvent e) { + if (e.getAction() == MotionEvent.ACTION_DOWN && currentHeader != null) { + final RecyclerView.ViewHolder viewHolder = currentHeader.second; + if (viewHolder != null && viewHolder.itemView != null) { + final int bottom = viewHolder.itemView.getBottom(); + return e.getY() <= bottom; + } + } + return super.onInterceptTouchEvent(rv, e); + } + }); + } + + @Override + public void onDrawOver(@NonNull final Canvas c, @NonNull final RecyclerView parent, @NonNull final RecyclerView.State state) { + super.onDrawOver(c, parent, state); + final View topChild = parent.findChildViewUnder( + parent.getPaddingLeft(), + parent.getPaddingTop() + ); + if (topChild == null) { + return; + } + final int topChildPosition = parent.getChildAdapterPosition(topChild); + if (topChildPosition == RecyclerView.NO_POSITION) { + return; + } + final View headerView = getHeaderViewForItem(topChildPosition, parent); + if (headerView == null) { + return; + } + final int contactPoint = headerView.getBottom() + parent.getPaddingTop(); + final View childInContact = getChildInContact(parent, contactPoint); + if (childInContact != null && callback.isHeader(parent.getChildAdapterPosition(childInContact))) { + moveHeader(c, headerView, childInContact, parent.getPaddingTop()); + return; + } + drawHeader(c, headerView, parent.getPaddingTop()); + } + + private void drawHeader(@NonNull final Canvas c, @NonNull final View header, final int paddingTop) { + c.save(); + c.translate(0f, paddingTop); + header.draw(c); + c.restore(); + } + + private void moveHeader(@NonNull final Canvas c, @NonNull final View currentHeader, @NonNull final View nextHeader, final int paddingTop) { + c.save(); + c.translate(0f, nextHeader.getTop() - currentHeader.getHeight() /*+ paddingTop*/); + currentHeader.draw(c); + c.restore(); + } + + @Nullable + private View getChildInContact(@NonNull final RecyclerView parent, final int contactPoint) { + View childInContact = null; + final int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + final Rect mBounds = new Rect(); + parent.getDecoratedBoundsWithMargins(child, mBounds); + if (mBounds.bottom > contactPoint) { + if (mBounds.top <= contactPoint) { + // This child overlaps the contactPoint + childInContact = child; + break; + } + } + } + return childInContact; + } + + @Nullable + private View getHeaderViewForItem(final int itemPosition, @NonNull final RecyclerView parent) { + if (parent.getAdapter() == null) { + return null; + } + final int headerPosition = getHeaderPositionForItem(itemPosition, parent.getAdapter()); + if (headerPosition == RecyclerView.NO_POSITION) return null; + final int headerType = parent.getAdapter().getItemViewType(headerPosition); + // if match reuse viewHolder + if (currentHeader != null + && currentHeader.first == headerPosition + && currentHeader.second.getItemViewType() == headerType) { + return currentHeader.second.itemView; + } + final RecyclerView.ViewHolder headerHolder = parent.getAdapter().createViewHolder(parent, headerType); + if (headerHolder != null) { + //noinspection unchecked + parent.getAdapter().onBindViewHolder(headerHolder, headerPosition); + fixLayoutSize(parent, headerHolder.itemView); + // save for next draw + currentHeader = new Pair<>(headerPosition, headerHolder); + return headerHolder.itemView; + } + return null; + } + + @SuppressWarnings("rawtypes") + private int getHeaderPositionForItem(final int itemPosition, final RecyclerView.Adapter adapter) { + int headerPosition = RecyclerView.NO_POSITION; + int currentPosition = itemPosition; + do { + if (callback.isHeader(currentPosition)) { + headerPosition = currentPosition; + break; + } + currentPosition += layoutReversed ? 1 : -1; + } while (layoutReversed ? currentPosition < adapter.getItemCount() : currentPosition >= 0); + return headerPosition; + } + + /** + * Properly measures and layouts the top sticky header. + * + * @param parent ViewGroup: RecyclerView in this case. + */ + private void fixLayoutSize(@NonNull final ViewGroup parent, @NonNull final View view) { + + // Specs for parent (RecyclerView) + final int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + final int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + // Specs for children (headers) + final int childWidthSpec = ViewGroup.getChildMeasureSpec( + widthSpec, + parent.getPaddingLeft() + parent.getPaddingRight(), + view.getLayoutParams().width + ); + final int childHeightSpec = ViewGroup.getChildMeasureSpec( + heightSpec, + parent.getPaddingTop() + parent.getPaddingBottom(), + view.getLayoutParams().height + ); + + view.measure(childWidthSpec, childHeightSpec); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + public View getCurrentHeader() { + return currentHeader == null ? null : currentHeader.second.itemView; + } + + public interface HeaderItemDecorationCallback { + boolean isHeader(int itemPosition); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/HeightProvider.java b/app/src/main/java/awais/instagrabber/customviews/helpers/HeightProvider.java new file mode 100644 index 0000000..9287c28 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/HeightProvider.java @@ -0,0 +1,66 @@ +package awais.instagrabber.customviews.helpers; + +import android.app.Activity; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.WindowManager.LayoutParams; +import android.widget.PopupWindow; + +public class HeightProvider extends PopupWindow implements OnGlobalLayoutListener { + private final Activity mActivity; + private final View rootView; + private HeightListener listener; + private int heightMax; + + public HeightProvider(Activity activity) { + super(activity); + this.mActivity = activity; + + rootView = new View(activity); + setContentView(rootView); + + rootView.getViewTreeObserver().addOnGlobalLayoutListener(this); + setBackgroundDrawable(new ColorDrawable(0)); + + setWidth(0); + setHeight(LayoutParams.MATCH_PARENT); + + setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED); + } + + public HeightProvider init() { + if (!isShowing()) { + final View view = mActivity.getWindow().getDecorView(); + view.post(() -> showAtLocation(view, Gravity.NO_GRAVITY, 0, 0)); + } + return this; + } + + public HeightProvider setHeightListener(HeightListener listener) { + this.listener = listener; + return this; + } + + @Override + public void onGlobalLayout() { + Rect rect = new Rect(); + rootView.getWindowVisibleDisplayFrame(rect); + if (rect.bottom > heightMax) { + heightMax = rect.bottom; + } + + int keyboardHeight = heightMax - rect.bottom; + if (listener != null) { + listener.onHeightChanged(keyboardHeight); + } + } + + public interface HeightListener { + void onHeightChanged(int height); + } +} + diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/ImageResizingControllerListener.java b/app/src/main/java/awais/instagrabber/customviews/helpers/ImageResizingControllerListener.java new file mode 100644 index 0000000..c38b89d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/ImageResizingControllerListener.java @@ -0,0 +1,43 @@ +package awais.instagrabber.customviews.helpers; + +import android.graphics.drawable.Animatable; +import android.view.ViewGroup; + +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.view.DraweeView; +import com.facebook.imagepipeline.image.ImageInfo; + +import awais.instagrabber.utils.NumberUtils; + +public class ImageResizingControllerListener> extends BaseControllerListener { + private static final String TAG = "ImageResizingController"; + + private T imageView; + private final int requiredWidth; + + public ImageResizingControllerListener(final T imageView, final int requiredWidth) { + this.imageView = imageView; + this.requiredWidth = requiredWidth; + } + + @Override + public void onIntermediateImageSet(final String id, final ImageInfo imageInfo) { + super.onIntermediateImageSet(id, imageInfo); + } + + public void onFinalImageSet(String id, ImageInfo imageInfo, Animatable animatable) { + if (imageInfo != null) { + // updateViewSize(imageInfo); + final int height = imageInfo.getHeight(); + final int width = imageInfo.getWidth(); + // final float aspectRatio = ((float) width) / height; + final ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams(); + // final int deviceWidth = Utils.displayMetrics.widthPixels; + final int resultingHeight = NumberUtils.getResultingHeight(requiredWidth, height, width); + layoutParams.width = requiredWidth; + layoutParams.height = resultingHeight; + imageView.requestLayout(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/NestedCoordinatorLayout.java b/app/src/main/java/awais/instagrabber/customviews/helpers/NestedCoordinatorLayout.java new file mode 100644 index 0000000..da0a1e9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/NestedCoordinatorLayout.java @@ -0,0 +1,154 @@ +package awais.instagrabber.customviews.helpers; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.NestedScrollingChild; +import androidx.core.view.NestedScrollingChildHelper; + +public class NestedCoordinatorLayout extends CoordinatorLayout implements NestedScrollingChild { + + private NestedScrollingChildHelper mChildHelper; + + public NestedCoordinatorLayout(Context context) { + super(context); + mChildHelper = new NestedScrollingChildHelper(this); + setNestedScrollingEnabled(true); + } + + public NestedCoordinatorLayout(Context context, AttributeSet attrs) { + super(context, attrs); + mChildHelper = new NestedScrollingChildHelper(this); + setNestedScrollingEnabled(true); + } + + public NestedCoordinatorLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + mChildHelper = new NestedScrollingChildHelper(this); + setNestedScrollingEnabled(true); + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) { + int[][] tConsumed = new int[2][2]; + super.onNestedPreScroll(target, dx, dy, consumed, type); + dispatchNestedPreScroll(dx, dy, tConsumed[1], null); + consumed[0] = tConsumed[0][0] + tConsumed[1][0]; + consumed[1] = tConsumed[0][1] + tConsumed[1][1]; + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) { + super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type); + dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null); + } + + @Override + public void onStopNestedScroll(View target, int type) { + /* Disable the scrolling behavior of our own children */ + super.onStopNestedScroll(target, type); + /* Disable the scrolling behavior of the parent's other children */ + stopNestedScroll(); + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes, int type) { + /* Enable the scrolling behavior of our own children */ + boolean tHandled = super.onStartNestedScroll(child, target, nestedScrollAxes, type); + /* Enable the scrolling behavior of the parent's other children */ + return startNestedScroll(nestedScrollAxes) || tHandled; + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + /* Enable the scrolling behavior of our own children */ + boolean tHandled = super.onStartNestedScroll(child, target, nestedScrollAxes); + /* Enable the scrolling behavior of the parent's other children */ + return startNestedScroll(nestedScrollAxes) || tHandled; + } + + @Override + public void onStopNestedScroll(View target) { + /* Disable the scrolling behavior of our own children */ + super.onStopNestedScroll(target); + /* Disable the scrolling behavior of the parent's other children */ + stopNestedScroll(); + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + int[][] tConsumed = new int[2][2]; + super.onNestedPreScroll(target, dx, dy, tConsumed[0]); + dispatchNestedPreScroll(dx, dy, tConsumed[1], null); + consumed[0] = tConsumed[0][0] + tConsumed[1][0]; + consumed[1] = tConsumed[0][1] + tConsumed[1][1]; + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); + dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null); + } + + @Override + public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + boolean tHandled = super.onNestedPreFling(target, velocityX, velocityY); + return dispatchNestedPreFling(velocityX, velocityY) || tHandled; + } + + @Override + public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + boolean tHandled = super.onNestedFling(target, velocityX, velocityY, consumed); + return dispatchNestedFling(velocityX, velocityY, consumed) || tHandled; + } + + @Override + public boolean isNestedScrollingEnabled() { + return mChildHelper.isNestedScrollingEnabled(); + } + + @Override + public void setNestedScrollingEnabled(boolean enabled) { + mChildHelper.setNestedScrollingEnabled(enabled); + } + + @Override + public boolean startNestedScroll(int axes) { + return mChildHelper.startNestedScroll(axes); + } + + @Override + public void stopNestedScroll() { + mChildHelper.stopNestedScroll(); + } + + @Override + public boolean hasNestedScrollingParent() { + return mChildHelper.hasNestedScrollingParent(); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, int[] offsetInWindow) { + return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, + dyUnconsumed, offsetInWindow); + } + + @Override + public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { + return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); + } + + @Override + public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { + return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedPreFling(float velocityX, float velocityY) { + return mChildHelper.dispatchNestedPreFling(velocityX, velocityY); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/NestedScrollableHost.java b/app/src/main/java/awais/instagrabber/customviews/helpers/NestedScrollableHost.java new file mode 100644 index 0000000..c3e56ec --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/NestedScrollableHost.java @@ -0,0 +1,112 @@ +package awais.instagrabber.customviews.helpers; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager2.widget.ViewPager2; + +import static androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL; + +public class NestedScrollableHost extends FrameLayout { + + private int touchSlop; + private float initialX = 0f; + private float initialY = 0f; + + public NestedScrollableHost(@NonNull final Context context) { + this(context, null); + } + + public NestedScrollableHost(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent ev) { + handleInterceptTouchEvent(ev); + return super.onInterceptTouchEvent(ev); + } + + private void handleInterceptTouchEvent(final MotionEvent e) { + if (getParentViewPager() == null) return; + final int orientation = getParentViewPager().getOrientation(); + // Early return if child can't scroll in same direction as parent + if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) return; + + if (e.getAction() == MotionEvent.ACTION_DOWN) { + initialX = e.getX(); + initialY = e.getY(); + getParent().requestDisallowInterceptTouchEvent(true); + } else if (e.getAction() == MotionEvent.ACTION_MOVE) { + final float dx = e.getX() - initialX; + final float dy = e.getY() - initialY; + final boolean isVpHorizontal = orientation == ORIENTATION_HORIZONTAL; + + // assuming ViewPager2 touch-slop is 2x touch-slop of child + final float scaledDx = Math.abs(dx) * (isVpHorizontal ? .5f : 1f); + final float scaledDy = Math.abs(dy) * (isVpHorizontal ? 1f : .5f); + + if (scaledDx > touchSlop || scaledDy > touchSlop) { + if (isVpHorizontal == (scaledDy > scaledDx)) { + // Gesture is perpendicular, allow all parents to intercept + getParent().requestDisallowInterceptTouchEvent(false); + } else { + // Gesture is parallel, query child if movement in that direction is possible + if (canChildScroll(orientation, (isVpHorizontal ? dx : dy))) { + // Child can scroll, disallow all parents to intercept + getParent().requestDisallowInterceptTouchEvent(true); + } else { + // Child cannot scroll, allow all parents to intercept + getParent().requestDisallowInterceptTouchEvent(false); + } + } + } + } + } + + private boolean canChildScroll(final int orientation, final float delta) { + final int direction = -(int) Math.signum(delta); + final View child = getChild(); + if (child == null) return false; + ViewPager2 viewPagerChild = null; + if (child instanceof ViewPager2) { + viewPagerChild = (ViewPager2) child; + } + + boolean canScroll; + switch (orientation) { + case 0: + canScroll = child.canScrollHorizontally(direction); + break; + case 1: + canScroll = child.canScrollVertically(direction); + break; + default: + throw new IllegalArgumentException(); + } + if (!canScroll || viewPagerChild == null || viewPagerChild.getAdapter() == null) + return canScroll; + // check if viewpager has reached its limits and decide accordingly + return (direction < 0 && viewPagerChild.getCurrentItem() > 0) + || (direction > 0 && viewPagerChild.getCurrentItem() < viewPagerChild.getAdapter().getItemCount() - 1); + } + + public ViewPager2 getParentViewPager() { + View v = (View) getParent(); + while (v != null && !(v instanceof ViewPager2)) { + v = (View) v.getParent(); + } + return (ViewPager2) v; + } + + public View getChild() { + return getChildCount() > 0 ? getChildAt(0) : null; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/PostFetcher.java b/app/src/main/java/awais/instagrabber/customviews/helpers/PostFetcher.java new file mode 100644 index 0000000..a017eba --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/PostFetcher.java @@ -0,0 +1,59 @@ +package awais.instagrabber.customviews.helpers; + +import android.util.Log; + +import java.util.List; + +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Media; + +public class PostFetcher { + private static final String TAG = PostFetcher.class.getSimpleName(); + + private final PostFetchService postFetchService; + private final FetchListener> fetchListener; + private boolean fetching; + + public PostFetcher(final PostFetchService postFetchService, + final FetchListener> fetchListener) { + this.postFetchService = postFetchService; + this.fetchListener = fetchListener; + } + + public void fetch() { + if (fetching) return; + fetching = true; + postFetchService.fetch(new FetchListener>() { + @Override + public void onResult(final List result) { + fetching = false; + fetchListener.onResult(result); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + } + }); + } + + public void reset() { + postFetchService.reset(); + } + + public boolean isFetching() { + return fetching; + } + + public boolean hasMore() { + return postFetchService.hasNextPage(); + } + + public interface PostFetchService { + void fetch(FetchListener> fetchListener); + + void reset(); + + boolean hasNextPage(); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/RecordViewAnimationHelper.java b/app/src/main/java/awais/instagrabber/customviews/helpers/RecordViewAnimationHelper.java new file mode 100644 index 0000000..3375c5b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/RecordViewAnimationHelper.java @@ -0,0 +1,205 @@ +package awais.instagrabber.customviews.helpers; + +import android.animation.AnimatorSet; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Handler; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.ImageView; + +import androidx.appcompat.widget.AppCompatImageView; +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; +import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; + +import awais.instagrabber.R; +import awais.instagrabber.customviews.RecordButton; +import awais.instagrabber.customviews.RecordView.OnBasketAnimationEnd; + +import static android.view.View.INVISIBLE; +import static android.view.View.VISIBLE; + +public class RecordViewAnimationHelper { + private static final String TAG = RecordViewAnimationHelper.class.getSimpleName(); + private final Context context; + private final AnimatedVectorDrawableCompat animatedVectorDrawable; + private final ImageView basketImg; + private final ImageView smallBlinkingMic; + private AlphaAnimation alphaAnimation; + private OnBasketAnimationEnd onBasketAnimationEndListener; + private boolean isBasketAnimating; + private boolean isStartRecorded = false; + private float micX = 0; + private float micY = 0; + private AnimatorSet micAnimation; + private TranslateAnimation translateAnimation1, translateAnimation2; + private Handler handler1, handler2; + + public RecordViewAnimationHelper(Context context, AppCompatImageView basketImg, AppCompatImageView smallBlinkingMic) { + this.context = context; + this.smallBlinkingMic = smallBlinkingMic; + this.basketImg = basketImg; + animatedVectorDrawable = AnimatedVectorDrawableCompat.create(context, R.drawable.recv_basket_animated); + } + + @SuppressLint("RestrictedApi") + public void animateBasket(float basketInitialY) { + isBasketAnimating = true; + + clearAlphaAnimation(false); + + //save initial x,y values for mic icon + if (micX == 0) { + micX = smallBlinkingMic.getX(); + micY = smallBlinkingMic.getY(); + } + + micAnimation = (AnimatorSet) AnimatorInflaterCompat.loadAnimator(context, R.animator.delete_mic_animation); + micAnimation.setTarget(smallBlinkingMic); // set the view you want to animate + + translateAnimation1 = new TranslateAnimation(0, 0, basketInitialY, basketInitialY - 90); + translateAnimation1.setDuration(250); + + translateAnimation2 = new TranslateAnimation(0, 0, basketInitialY - 90, basketInitialY); + translateAnimation2.setDuration(350); + + micAnimation.start(); + basketImg.setImageDrawable(animatedVectorDrawable); + + handler1 = new Handler(); + handler1.postDelayed(() -> { + basketImg.setVisibility(VISIBLE); + basketImg.startAnimation(translateAnimation1); + }, 350); + + translateAnimation1.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + animatedVectorDrawable.start(); + handler2 = new Handler(); + handler2.postDelayed(() -> { + basketImg.startAnimation(translateAnimation2); + smallBlinkingMic.setVisibility(INVISIBLE); + basketImg.setVisibility(INVISIBLE); + }, 450); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + translateAnimation2.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + basketImg.setVisibility(INVISIBLE); + isBasketAnimating = false; + //if the user pressed the record button while the animation is running + // then do NOT call on Animation end + if (onBasketAnimationEndListener != null && !isStartRecorded) { + onBasketAnimationEndListener.onAnimationEnd(); + } + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + } + + //if the user started a new Record while the Animation is running + // then we want to stop the current animation and revert views back to default state + public void resetBasketAnimation() { + if (isBasketAnimating) { + translateAnimation1.reset(); + translateAnimation1.cancel(); + translateAnimation2.reset(); + translateAnimation2.cancel(); + micAnimation.cancel(); + smallBlinkingMic.clearAnimation(); + basketImg.clearAnimation(); + if (handler1 != null) { + handler1.removeCallbacksAndMessages(null); + } + if (handler2 != null) { + handler2.removeCallbacksAndMessages(null); + } + basketImg.setVisibility(INVISIBLE); + smallBlinkingMic.setX(micX); + smallBlinkingMic.setY(micY); + smallBlinkingMic.setVisibility(View.GONE); + isBasketAnimating = false; + } + } + + public void clearAlphaAnimation(boolean hideView) { + if (alphaAnimation != null) { + alphaAnimation.cancel(); + alphaAnimation.reset(); + } + smallBlinkingMic.clearAnimation(); + if (hideView) { + smallBlinkingMic.setVisibility(View.GONE); + } + } + + public void animateSmallMicAlpha() { + alphaAnimation = new AlphaAnimation(0.0f, 1.0f); + alphaAnimation.setDuration(500); + alphaAnimation.setRepeatMode(Animation.REVERSE); + alphaAnimation.setRepeatCount(Animation.INFINITE); + smallBlinkingMic.startAnimation(alphaAnimation); + } + + public void moveRecordButtonAndSlideToCancelBack(final RecordButton recordBtn, View slideToCancelLayout, float initialX, float difX) { + final ValueAnimator positionAnimator = ValueAnimator.ofFloat(recordBtn.getX(), initialX); + positionAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); + positionAnimator.addUpdateListener(animation -> { + float x = (Float) animation.getAnimatedValue(); + recordBtn.setX(x); + }); + recordBtn.stopScale(); + positionAnimator.setDuration(200); + positionAnimator.start(); + + // if the move event was not called ,then the difX will still 0 and there is no need to move it back + if (difX != 0) { + float x = initialX - difX; + slideToCancelLayout.animate() + .x(x) + .setDuration(0) + .start(); + } + } + + public void resetSmallMic() { + smallBlinkingMic.setAlpha(1.0f); + smallBlinkingMic.setScaleX(1.0f); + smallBlinkingMic.setScaleY(1.0f); + } + + public void setOnBasketAnimationEndListener(OnBasketAnimationEnd onBasketAnimationEndListener) { + this.onBasketAnimationEndListener = onBasketAnimationEndListener; + + } + + public void onAnimationEnd() { + if (onBasketAnimationEndListener != null) { + onBasketAnimationEndListener.onAnimationEnd(); + } + } + + //check if the user started a new Record by pressing the RecordButton + public void setStartRecorded(boolean startRecorded) { + isStartRecorded = startRecorded; + } + +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoader.java b/app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoader.java new file mode 100755 index 0000000..37de2ff --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoader.java @@ -0,0 +1,112 @@ +package awais.instagrabber.customviews.helpers; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; + +import awais.instagrabber.interfaces.LazyLoadListener; + +/** + * thanks to nesquena's EndlessRecyclerViewScrollListener + */ +public final class RecyclerLazyLoader extends RecyclerView.OnScrollListener { + /** + * The current offset index of data you have loaded + */ + private int currentPage = 0; + /** + * The total number of items in the data set after the last load + */ + private int previousTotalItemCount = 0; + /** + * true if we are still waiting for the last set of data to load. + */ + private boolean loading = true; + /** + * The minimum amount of items to have below your current scroll position before loading more. + */ + private final int visibleThreshold; + private final LazyLoadListener lazyLoadListener; + private final RecyclerView.LayoutManager layoutManager; + + public RecyclerLazyLoader(@NonNull final RecyclerView.LayoutManager layoutManager, + final LazyLoadListener lazyLoadListener, + final int threshold) { + this.layoutManager = layoutManager; + this.lazyLoadListener = lazyLoadListener; + if (threshold > 0) { + this.visibleThreshold = threshold; + return; + } + if (layoutManager instanceof GridLayoutManager) { + this.visibleThreshold = 5 * Math.max(3, ((GridLayoutManager) layoutManager).getSpanCount()); + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + this.visibleThreshold = 4 * Math.max(3, ((StaggeredGridLayoutManager) layoutManager).getSpanCount()); + } else if (layoutManager instanceof LinearLayoutManager) { + this.visibleThreshold = ((LinearLayoutManager) layoutManager).getReverseLayout() ? 4 : 8; + } else { + this.visibleThreshold = 5; + } + } + + public RecyclerLazyLoader(@NonNull final RecyclerView.LayoutManager layoutManager, + final LazyLoadListener lazyLoadListener) { + this(layoutManager, lazyLoadListener, -1); + } + + @Override + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + final int totalItemCount = layoutManager.getItemCount(); + + if (totalItemCount < previousTotalItemCount) { + currentPage = 0; + previousTotalItemCount = totalItemCount; + if (totalItemCount == 0) loading = true; + } + + if (loading && totalItemCount > previousTotalItemCount) { + loading = false; + previousTotalItemCount = totalItemCount; + } + + int lastVisibleItemPosition; + if (layoutManager instanceof GridLayoutManager) { + final GridLayoutManager layoutManager = (GridLayoutManager) this.layoutManager; + lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + final StaggeredGridLayoutManager layoutManager = (StaggeredGridLayoutManager) this.layoutManager; + final int spanCount = layoutManager.getSpanCount(); + final int[] lastVisibleItemPositions = layoutManager.findLastVisibleItemPositions(null); + lastVisibleItemPosition = 0; + for (final int itemPosition : lastVisibleItemPositions) { + if (itemPosition > lastVisibleItemPosition) { + lastVisibleItemPosition = itemPosition; + } + } + } else { + final LinearLayoutManager layoutManager = (LinearLayoutManager) this.layoutManager; + lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); + } + + if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) { + loading = true; + if (lazyLoadListener != null) { + new Handler().postDelayed(() -> lazyLoadListener.onLoadMore(++currentPage, totalItemCount), 200); + } + } + } + + public int getCurrentPage() { + return currentPage; + } + + public void resetState() { + this.currentPage = 0; + this.previousTotalItemCount = 0; + this.loading = true; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoaderAtEdge.java b/app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoaderAtEdge.java new file mode 100644 index 0000000..3d5cf30 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoaderAtEdge.java @@ -0,0 +1,61 @@ +package awais.instagrabber.customviews.helpers; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public final class RecyclerLazyLoaderAtEdge extends RecyclerView.OnScrollListener { + + private final RecyclerView.LayoutManager layoutManager; + private final LazyLoadListener lazyLoadListener; + private final boolean atTop; + private int currentPage; + private int previousItemCount; + private boolean loading; + + public RecyclerLazyLoaderAtEdge(@NonNull final RecyclerView.LayoutManager layoutManager, + final LazyLoadListener lazyLoadListener) { + this.layoutManager = layoutManager; + this.atTop = false; + this.lazyLoadListener = lazyLoadListener; + } + + public RecyclerLazyLoaderAtEdge(@NonNull final RecyclerView.LayoutManager layoutManager, + final boolean atTop, + final LazyLoadListener lazyLoadListener) { + this.layoutManager = layoutManager; + this.atTop = atTop; + this.lazyLoadListener = lazyLoadListener; + } + + @Override + public void onScrollStateChanged(@NonNull final RecyclerView recyclerView, final int newState) { + super.onScrollStateChanged(recyclerView, newState); + final int itemCount = layoutManager.getItemCount(); + if (itemCount > previousItemCount) { + loading = false; + } + if (!recyclerView.canScrollVertically(atTop ? -1 : 1) + && newState == RecyclerView.SCROLL_STATE_IDLE + && !loading + && lazyLoadListener != null) { + loading = true; + new Handler().postDelayed(() -> lazyLoadListener.onLoadMore(++currentPage), 300); + } + } + + public int getCurrentPage() { + return currentPage; + } + + public void resetState() { + currentPage = 0; + previousItemCount = 0; + loading = true; + } + + public interface LazyLoadListener { + void onLoadMore(final int page); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java new file mode 100644 index 0000000..f58be88 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java @@ -0,0 +1,139 @@ +package awais.instagrabber.customviews.helpers;/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A class which extends/implements both [WindowInsetsAnimationCompat.Callback] and + * [View.OnApplyWindowInsetsListener], which should be set on the root view in your layout. + *

+ * This class enables the root view is selectively defer handling any insets which match + * [deferredInsetTypes], to enable better looking [WindowInsetsAnimationCompat]s. + *

+ * An example is the following: when a [WindowInsetsAnimationCompat] is started, the system will dispatch + * a [WindowInsetsCompat] instance which contains the end state of the animation. For the scenario of + * the IME being animated in, that means that the insets contains the IME height. If the view's + * [View.OnApplyWindowInsetsListener] simply always applied the combination of + * [WindowInsetsCompat.Type.ime] and [WindowInsetsCompat.Type.systemBars] using padding, the viewport of any + * child views would then be smaller. This results in us animating a smaller (padded-in) view into + * a larger viewport. Visually, this results in the views looking clipped. + *

+ * This class allows us to implement a different strategy for the above scenario, by selectively + * deferring the [WindowInsetsCompat.Type.ime] insets until the [WindowInsetsAnimationCompat] is ended. + * For the above example, you would create a [RootViewDeferringInsetsCallback] like so: + *

+ * ``` + * val callback = RootViewDeferringInsetsCallback( + * persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), + * deferredInsetTypes = WindowInsetsCompat.Type.ime() + * ) + * ``` + *

+ * This class is not limited to just IME animations, and can work with any [WindowInsetsCompat.Type]s. + */ +public class RootViewDeferringInsetsCallback extends WindowInsetsAnimationCompat.Callback implements OnApplyWindowInsetsListener { + + private final int persistentInsetTypes; + private final int deferredInsetTypes; + @Nullable + private View view = null; + @Nullable + private WindowInsetsCompat lastWindowInsets = null; + private boolean deferredInsets = false; + + /** + * @param persistentInsetTypes the bitmask of any inset types which should always be handled + * through padding the attached view + * @param deferredInsetTypes the bitmask of insets types which should be deferred until after + * any related [WindowInsetsAnimationCompat]s have ended + */ + public RootViewDeferringInsetsCallback(final int persistentInsetTypes, final int deferredInsetTypes) { + super(DISPATCH_MODE_CONTINUE_ON_SUBTREE); + if ((persistentInsetTypes & deferredInsetTypes) != 0) { + throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + + "any of same WindowInsetsCompat.Type values"); + } + this.persistentInsetTypes = persistentInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + } + + @Override + public WindowInsetsCompat onApplyWindowInsets(@NonNull final View v, @NonNull final WindowInsetsCompat windowInsets) { + // Store the view and insets for us in onEnd() below + view = v; + lastWindowInsets = windowInsets; + + final int types = deferredInsets + // When the deferred flag is enabled, we only use the systemBars() insets + ? persistentInsetTypes + // Otherwise we handle the combination of the the systemBars() and ime() insets + : persistentInsetTypes | deferredInsetTypes; + + // Finally we apply the resolved insets by setting them as padding + final Insets typeInsets = windowInsets.getInsets(types); + v.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, typeInsets.bottom); + + // We return the new WindowInsetsCompat.CONSUMED to stop the insets being dispatched any + // further into the view hierarchy. This replaces the deprecated + // WindowInsetsCompat.consumeSystemWindowInsets() and related functions. + return WindowInsetsCompat.CONSUMED; + } + + @Override + public void onPrepare(WindowInsetsAnimationCompat animation) { + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + // We defer the WindowInsetsCompat.Type.ime() insets if the IME is currently not visible. + // This results in only the WindowInsetsCompat.Type.systemBars() being applied, allowing + // the scrolling view to remain at it's larger size. + deferredInsets = true; + } + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnims) { + // This is a no-op. We don't actually want to handle any WindowInsetsAnimations + return insets; + } + + @Override + public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { + if (deferredInsets && (animation.getTypeMask() & deferredInsetTypes) != 0) { + // If we deferred the IME insets and an IME animation has finished, we need to reset + // the flag + deferredInsets = false; + + // And finally dispatch the deferred insets to the view now. + // Ideally we would just call view.requestApplyInsets() and let the normal dispatch + // cycle happen, but this happens too late resulting in a visual flicker. + // Instead we manually dispatch the most recent WindowInsets to the view. + if (lastWindowInsets != null && view != null) { + ViewCompat.dispatchApplyWindowInsets(view, lastWindowInsets); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java b/app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java new file mode 100644 index 0000000..9bcc24d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java @@ -0,0 +1,443 @@ +/* + * Copyright 2020 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 awais.instagrabber.customviews.helpers; + +import android.os.CancellationSignal; +import android.util.Log; +import android.view.View; +import android.view.animation.LinearInterpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationControlListenerCompat; +import androidx.core.view.WindowInsetsAnimationControllerCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.dynamicanimation.animation.FloatPropertyCompat; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; + +import awais.instagrabber.utils.ViewUtils; + +/** + * A wrapper around the [WindowInsetsAnimationControllerCompat] APIs in AndroidX Core, to simplify + * the implementation of common use-cases around the IME. + *

+ * See [InsetsAnimationLinearLayout] and [InsetsAnimationTouchListener] for examples of how + * to use this class. + */ +public class SimpleImeAnimationController { + private static final String TAG = SimpleImeAnimationController.class.getSimpleName(); + /** + * Scroll threshold for determining whether to animating to the end state, or to the start state. + * Currently 15% of the total swipe distance distance + */ + private static final float SCROLL_THRESHOLD = 0.15f; + + @Nullable + private WindowInsetsAnimationControllerCompat insetsAnimationController = null; + @Nullable + private CancellationSignal pendingRequestCancellationSignal = null; + @Nullable + private OnRequestReadyListener pendingRequestOnReadyListener; + /** + * True if the IME was shown at the start of the current animation. + */ + private boolean isImeShownAtStart = false; + @Nullable + private SpringAnimation currentSpringAnimation = null; + private WindowInsetsAnimationControlListenerCompat fwdListener; + + /** + * A LinearInterpolator instance we can re-use across listeners. + */ + private final LinearInterpolator linearInterpolator = new LinearInterpolator(); + /* To take control of the an WindowInsetsAnimation, we need to pass in a listener to + controlWindowInsetsAnimation() in startControlRequest(). The listener created here + keeps track of the current WindowInsetsAnimationController and resets our state. */ + private final WindowInsetsAnimationControlListenerCompat animationControlListener = new WindowInsetsAnimationControlListenerCompat() { + /** + * Once the request is ready, call our [onRequestReady] function + */ + @Override + public void onReady(@NonNull final WindowInsetsAnimationControllerCompat controller, final int types) { + onRequestReady(controller); + if (fwdListener != null) { + fwdListener.onReady(controller, types); + } + } + + /** + * If the request is finished, we should reset our internal state + */ + @Override + public void onFinished(@NonNull final WindowInsetsAnimationControllerCompat controller) { + reset(); + if (fwdListener != null) { + fwdListener.onFinished(controller); + } + } + + /** + * If the request is cancelled, we should reset our internal state + */ + @Override + public void onCancelled(@Nullable final WindowInsetsAnimationControllerCompat controller) { + reset(); + if (fwdListener != null) { + fwdListener.onCancelled(controller); + } + } + }; + + /** + * Start a control request to the [view]s [android.view.WindowInsetsController]. This should + * be called once the view is in a position to take control over the position of the IME. + * + * @param view The view which is triggering this request + * @param onRequestReadyListener optional listener which will be called when the request is ready and + * the animation can proceed + */ + public void startControlRequest(@NonNull final View view, + @Nullable final OnRequestReadyListener onRequestReadyListener) { + if (isInsetAnimationInProgress()) { + Log.w(TAG, "startControlRequest: Animation in progress. Can not start a new request to controlWindowInsetsAnimation()"); + return; + } + + // Keep track of the IME insets, and the IME visibility, at the start of the request + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (rootWindowInsets != null) { + isImeShownAtStart = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + } + + // Create a cancellation signal, which we pass to controlWindowInsetsAnimation() below + pendingRequestCancellationSignal = new CancellationSignal(); + // Keep reference to the onReady callback + pendingRequestOnReadyListener = onRequestReadyListener; + + // Finally we make a controlWindowInsetsAnimation() request: + final WindowInsetsControllerCompat windowInsetsController = ViewCompat.getWindowInsetsController(view); + if (windowInsetsController != null) { + windowInsetsController.controlWindowInsetsAnimation( + // We're only catering for IME animations in this listener + WindowInsetsCompat.Type.ime(), + // Animation duration. This is not used by the system, and is only passed to any + // WindowInsetsAnimation.Callback set on views. We pass in -1 to indicate that we're + // not starting a finite animation, and that this is completely controlled by + // the user's touch. + -1, + // The time interpolator used in calculating the animation progress. The fraction value + // we passed into setInsetsAndAlpha() which be passed into this interpolator before + // being used by the system to inset the IME. LinearInterpolator is a good type + // to use for scrolling gestures. + linearInterpolator, + // A cancellation signal, which allows us to cancel the request to control + pendingRequestCancellationSignal, + // The WindowInsetsAnimationControlListener + animationControlListener + ); + } + } + + /** + * Start a control request to the [view]s [android.view.WindowInsetsController], similar to + * [startControlRequest], but immediately fling to a finish using [velocityY] once ready. + *

+ * This function is useful for fire-and-forget operations to animate the IME. + * + * @param view The view which is triggering this request + * @param velocityY the velocity of the touch gesture which caused this call + */ + public void startAndFling(@NonNull final View view, final float velocityY) { + startControlRequest(view, null); + animateToFinish(velocityY); + } + + /** + * Update the inset position of the IME by the given [dy] value. This value will be coerced + * into the hidden and shown inset values. + *

+ * This function should only be called if [isInsetAnimationInProgress] returns true. + * + * @return the amount of [dy] consumed by the inset animation, in pixels + */ + public int insetBy(final int dy) { + if (insetsAnimationController == null) { + throw new IllegalStateException("Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true"); + } + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + // Call updateInsetTo() with the new inset value + return insetTo(controller.getCurrentInsets().bottom - dy); + } + + /** + * Update the inset position of the IME to be the given [inset] value. This value will be + * coerced into the hidden and shown inset values. + *

+ * This function should only be called if [isInsetAnimationInProgress] returns true. + * + * @return the distance moved by the inset animation, in pixels + */ + public int insetTo(final int inset) { + if (insetsAnimationController == null) { + throw new IllegalStateException("Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true"); + } + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + final int hiddenBottom = controller.getHiddenStateInsets().bottom; + final int shownBottom = controller.getShownStateInsets().bottom; + final int startBottom = isImeShownAtStart ? shownBottom : hiddenBottom; + final int endBottom = isImeShownAtStart ? hiddenBottom : shownBottom; + + // We coerce the given inset within the limits of the hidden and shown insets + final int coercedBottom = coerceIn(inset, hiddenBottom, shownBottom); + + final int consumedDy = controller.getCurrentInsets().bottom - coercedBottom; + + // Finally update the insets in the WindowInsetsAnimationController using + // setInsetsAndAlpha(). + controller.setInsetsAndAlpha( + // Here we update the animating insets. This is what controls where the IME is displayed. + // It is also passed through to views via their WindowInsetsAnimation.Callback. + Insets.of(0, 0, 0, coercedBottom), + // This controls the alpha value. We don't want to alter the alpha so use 1f + 1f, + // Finally we calculate the animation progress fraction. This value is passed through + // to any WindowInsetsAnimation.Callbacks, but it is not used by the system. + (coercedBottom - startBottom) / (float) (endBottom - startBottom) + ); + + return consumedDy; + } + + /** + * Return `true` if an inset animation is in progress. + */ + public boolean isInsetAnimationInProgress() { + return insetsAnimationController != null; + } + + /** + * Return `true` if an inset animation is currently finishing. + */ + public boolean isInsetAnimationFinishing() { + return currentSpringAnimation != null; + } + + /** + * Return `true` if a request to control an inset animation is in progress. + */ + public boolean isInsetAnimationRequestPending() { + return pendingRequestCancellationSignal != null; + } + + /** + * Cancel the current [WindowInsetsAnimationControllerCompat]. We immediately finish + * the animation, reverting back to the state at the start of the gesture. + */ + public void cancel() { + if (insetsAnimationController != null) { + insetsAnimationController.finish(isImeShownAtStart); + } + if (pendingRequestCancellationSignal != null) { + pendingRequestCancellationSignal.cancel(); + } + if (currentSpringAnimation != null) { + // Cancel the current spring animation + currentSpringAnimation.cancel(); + } + reset(); + } + + /** + * Finish the current [WindowInsetsAnimationControllerCompat] immediately. + */ + public void finish() { + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + if (controller == null) { + // If we don't currently have a controller, cancel any pending request and return + if (pendingRequestCancellationSignal != null) { + pendingRequestCancellationSignal.cancel(); + } + return; + } + + final int current = controller.getCurrentInsets().bottom; + final int shown = controller.getShownStateInsets().bottom; + final int hidden = controller.getHiddenStateInsets().bottom; + + // The current inset matches either the shown/hidden inset, finish() immediately + if (current == shown) { + controller.finish(true); + } else if (current == hidden) { + controller.finish(false); + } else { + // Otherwise, we'll look at the current position... + if (controller.getCurrentFraction() >= SCROLL_THRESHOLD) { + // If the IME is past the 'threshold' we snap to the toggled state + controller.finish(!isImeShownAtStart); + } else { + // ...otherwise, we snap back to the original visibility + controller.finish(isImeShownAtStart); + } + } + } + + /** + * Finish the current [WindowInsetsAnimationControllerCompat]. We finish the animation, + * animating to the end state if necessary. + * + * @param velocityY the velocity of the touch gesture which caused this call to [animateToFinish]. + * Can be `null` if velocity is not available. + */ + public void animateToFinish(@Nullable final Float velocityY) { + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + if (controller == null) { + // If we don't currently have a controller, cancel any pending request and return + if (pendingRequestCancellationSignal != null) { + pendingRequestCancellationSignal.cancel(); + } + return; + } + + final int current = controller.getCurrentInsets().bottom; + final int shown = controller.getShownStateInsets().bottom; + final int hidden = controller.getHiddenStateInsets().bottom; + + if (velocityY != null) { + // If we have a velocity, we can use it's direction to determine + // the visibility. Upwards == visible + animateImeToVisibility(velocityY > 0, velocityY); + } else if (current == shown) { + // The current inset matches either the shown/hidden inset, finish() immediately + controller.finish(true); + } else if (current == hidden) { + controller.finish(false); + } else { + // Otherwise, we'll look at the current position... + if (controller.getCurrentFraction() >= SCROLL_THRESHOLD) { + // If the IME is past the 'threshold' we animate it to the toggled state + animateImeToVisibility(!isImeShownAtStart, null); + } else { + // ...otherwise, we animate it back to the original visibility + animateImeToVisibility(isImeShownAtStart, null); + } + } + } + + private void onRequestReady(@NonNull final WindowInsetsAnimationControllerCompat controller) { + // The request is ready, so clear out the pending cancellation signal + pendingRequestCancellationSignal = null; + // Store the current WindowInsetsAnimationController + insetsAnimationController = controller; + + // Call any pending callback + if (pendingRequestOnReadyListener != null) { + pendingRequestOnReadyListener.onRequestReady(controller); + } + pendingRequestOnReadyListener = null; + } + + /** + * Resets all of our internal state. + */ + private void reset() { + // Clear all of our internal state + insetsAnimationController = null; + pendingRequestCancellationSignal = null; + isImeShownAtStart = false; + if (currentSpringAnimation != null) { + currentSpringAnimation.cancel(); + } + currentSpringAnimation = null; + pendingRequestOnReadyListener = null; + } + + /** + * Animate the IME to a given visibility. + * + * @param visible `true` to animate the IME to it's fully shown state, `false` to it's + * fully hidden state. + * @param velocityY the velocity of the touch gesture which caused this call. Can be `null` + * if velocity is not available. + */ + private void animateImeToVisibility(final boolean visible, @Nullable final Float velocityY) { + if (insetsAnimationController == null) { + throw new IllegalStateException("Controller should not be null"); + } + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + final FloatPropertyCompat property = new FloatPropertyCompat("property") { + @Override + public float getValue(final Object object) { + return controller.getCurrentInsets().bottom; + } + + @Override + public void setValue(final Object object, final float value) { + if (insetsAnimationController == null) { + return; + } + insetTo((int) value); + } + }; + final float finalPosition = visible ? controller.getShownStateInsets().bottom + : controller.getHiddenStateInsets().bottom; + final SpringForce force = new SpringForce(finalPosition) + // Tweak the damping value, to remove any bounciness. + .setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) + // The stiffness value controls the strength of the spring animation, which + // controls the speed. Medium (the default) is a good value, but feel free to + // play around with this value. + .setStiffness(SpringForce.STIFFNESS_MEDIUM); + ViewUtils.springAnimationOf(this, property, finalPosition) + .setSpring(force) + .setStartVelocity(velocityY != null ? velocityY : 0) + .addEndListener((animation, canceled, value, velocity) -> { + if (animation == currentSpringAnimation) { + currentSpringAnimation = null; + } + // Once the animation has ended, finish the controller + finish(); + }).start(); + } + + private int coerceIn(final int v, final int min, final int max) { + if (v >= min && v <= max) { + return v; + } + if (v < min) { + return min; + } + return max; + } + + public void setAnimationControlListener(final WindowInsetsAnimationControlListenerCompat listener) { + fwdListener = listener; + } + + public interface OnRequestReadyListener { + void onRequestReady(WindowInsetsAnimationControllerCompat windowInsetsAnimationControllerCompat); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java new file mode 100644 index 0000000..dee19e6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java @@ -0,0 +1,184 @@ +package awais.instagrabber.customviews.helpers; + + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.utils.Utils; + +/** + * Thanks to https://github.com/izjumovfs/SwipeToReply/blob/master/swipetoreply/src/main/java/com/capybaralabs/swipetoreply/SwipeController.java + */ +public class SwipeAndRestoreItemTouchHelperCallback extends ItemTouchHelper.Callback { + private static final String TAG = "SwipeRestoreCallback"; + + private final float swipeThreshold; + private final float swipeAutoCancelThreshold; + private final OnSwipeListener onSwipeListener; + private final Drawable replyIcon; + // private final Drawable replyIconBackground; + private final int replyIconShowThreshold; + private final float replyIconMaxTranslation; + private final Rect replyIconBounds = new Rect(); + private final float replyIconXOffset; + private final int replyIconSize; + + private boolean mSwipeBack = false; + private boolean hasVibrated; + + public SwipeAndRestoreItemTouchHelperCallback(final Context context, final OnSwipeListener onSwipeListener) { + this.onSwipeListener = onSwipeListener; + swipeThreshold = Utils.displayMetrics.widthPixels * 0.25f; + swipeAutoCancelThreshold = swipeThreshold + Utils.convertDpToPx(5); + replyIcon = AppCompatResources.getDrawable(context, R.drawable.ic_round_reply_24); + if (replyIcon == null) { + throw new IllegalArgumentException("reply icon is null"); + } + replyIcon.setTint(context.getResources().getColor(R.color.white)); //todo need to update according to theme + replyIconShowThreshold = Utils.convertDpToPx(24); + replyIconMaxTranslation = swipeThreshold - replyIconShowThreshold; + // Log.d(TAG, "replyIconShowThreshold: " + replyIconShowThreshold + ", swipeThreshold: " + swipeThreshold); + replyIconSize = replyIconShowThreshold; // Utils.convertDpToPx(24); + replyIconXOffset = swipeThreshold * 0.25f /*Utils.convertDpToPx(20)*/; + } + + @Override + public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder instanceof SwipeableViewHolder)) { + return makeMovementFlags(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.ACTION_STATE_IDLE); + } + return makeMovementFlags(ItemTouchHelper.ACTION_STATE_IDLE, ((SwipeableViewHolder) viewHolder).getSwipeDirection()); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder viewHolder1) { + return false; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int i) {} + + @Override + public int convertToAbsoluteDirection(int flags, int layoutDirection) { + if (mSwipeBack) { + mSwipeBack = false; + return 0; + } + return super.convertToAbsoluteDirection(flags, layoutDirection); + } + + @Override + public void onChildDraw(@NonNull Canvas c, + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dX, + float dY, + int actionState, + boolean isCurrentlyActive) { + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + setTouchListener(recyclerView, viewHolder); + } + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + drawReplyButton(c, viewHolder); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder) { + recyclerView.setOnTouchListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_MOVE) { + if (Math.abs(viewHolder.itemView.getTranslationX()) >= swipeAutoCancelThreshold) { + if (!hasVibrated) { + viewHolder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING); + hasVibrated = true; + } + // MotionEvent cancelEvent = MotionEvent.obtain(event); + // cancelEvent.setAction(MotionEvent.ACTION_CANCEL); + // recyclerView.dispatchTouchEvent(cancelEvent); + // cancelEvent.recycle(); + } + } + mSwipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP; + if (mSwipeBack) { + hasVibrated = false; + if (Math.abs(viewHolder.itemView.getTranslationX()) >= swipeThreshold) { + if (onSwipeListener != null) { + onSwipeListener.onSwipe(viewHolder.getBindingAdapterPosition(), viewHolder); + } + } + } + return false; + }); + } + + public interface SwipeableViewHolder { + int getSwipeDirection(); + } + + public interface OnSwipeListener { + void onSwipe(final int adapterPosition, final RecyclerView.ViewHolder viewHolder); + } + + private void drawReplyButton(Canvas canvas, final RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder instanceof SwipeableViewHolder)) return; + final int swipeDirection = ((SwipeableViewHolder) viewHolder).getSwipeDirection(); + if (swipeDirection != ItemTouchHelper.START && swipeDirection != ItemTouchHelper.END) return; + final View view = viewHolder.itemView; + float translationX = view.getTranslationX(); + boolean show = false; + float progress; + final float translationXAbs = Math.abs(translationX); + if (translationXAbs >= replyIconShowThreshold) { + show = true; + } + if (show) { + // replyIconShowThreshold -> swipeThreshold <=> progress 0 -> 1 + final float replyIconTranslation = translationXAbs - replyIconShowThreshold; + progress = replyIconTranslation / replyIconMaxTranslation; + if (progress > 1) { + progress = 1f; + } + if (progress < 0) { + progress = 0; + } + // Log.d(TAG, /*"translationX: " + translationX + ", replyIconTranslation: " + replyIconTranslation +*/ "progress: " + progress); + } else { + progress = 0f; + // Log.d(TAG, /*"translationX: " + translationX + ", replyIconTranslation: " + 0 +*/ "progress: " + progress); + } + if (progress > 0) { + // calculate the reply icon y position, then offset top, bottom with icon size + final int y = view.getTop() + (view.getMeasuredHeight() / 2); + final int tempIconSize = (int) (replyIconSize * progress); + final int tempIconSizeHalf = tempIconSize / 2; + final int xOffset = (int) (replyIconXOffset * progress); + final int left; + if (swipeDirection == ItemTouchHelper.END) { + // draw arrow of left side + left = xOffset; + } else { + // draw arrow of right side + left = view.getMeasuredWidth() - xOffset - tempIconSize; + } + final int right = tempIconSize + left; + replyIconBounds.set(left, y - tempIconSizeHalf, right, y + tempIconSizeHalf); + replyIcon.setBounds(replyIconBounds); + replyIcon.draw(canvas); + } + } + +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeGestureListener.java b/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeGestureListener.java new file mode 100755 index 0000000..fcd3f80 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeGestureListener.java @@ -0,0 +1,34 @@ +package awais.instagrabber.customviews.helpers; + +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; + +import awais.instagrabber.interfaces.SwipeEvent; + +public final class SwipeGestureListener extends GestureDetector.SimpleOnGestureListener { + public static final int SWIPE_THRESHOLD = 200; + public static final int SWIPE_VELOCITY_THRESHOLD = 200; + private final SwipeEvent swipeEvent; + + public SwipeGestureListener(final SwipeEvent swipeEvent) { + this.swipeEvent = swipeEvent; + } + + @Override + public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float velocityX, final float velocityY) { + try { + final float diffY = e2.getY() - e1.getY(); + final float diffX = e2.getX() - e1.getX(); + final float diffXAbs = Math.abs(diffX); + if (diffXAbs > Math.abs(diffY) && diffXAbs > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { + if (diffX > 0) swipeEvent.onSwipe(true); + else swipeEvent.onSwipe(false); + return true; + } + } catch (final Exception e) { + Log.e("AWAISKING_APP", "", e); + } + return false; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/TextWatcherAdapter.java b/app/src/main/java/awais/instagrabber/customviews/helpers/TextWatcherAdapter.java new file mode 100644 index 0000000..f989a14 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/TextWatcherAdapter.java @@ -0,0 +1,15 @@ +package awais.instagrabber.customviews.helpers; + +import android.text.Editable; +import android.text.TextWatcher; + +public class TextWatcherAdapter implements TextWatcher { + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {} + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) {} + + @Override + public void afterTextChanged(final Editable s) {} +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java new file mode 100644 index 0000000..e10105e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java @@ -0,0 +1,128 @@ +/* + * Copyright 2020 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 awais.instagrabber.customviews.helpers; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A [WindowInsetsAnimationCompat.Callback] which will translate/move the given view during any + * inset animations of the given inset type. + *

+ * This class works in tandem with [RootViewDeferringInsetsCallback] to support the deferring of + * certain [WindowInsetsCompat.Type] values during a [WindowInsetsAnimationCompat], provided in + * [deferredInsetTypes]. The values passed into this constructor should match those which + * the [RootViewDeferringInsetsCallback] is created with. + */ +public class TranslateDeferringInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + private final View view; + private final int persistentInsetTypes; + private final int deferredInsetTypes; + + private boolean shouldTranslate = true; + private int kbHeight; + + public TranslateDeferringInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes) { + this(view, persistentInsetTypes, deferredInsetTypes, DISPATCH_MODE_STOP); + } + + /** + * @param view the view to translate from it's start to end state + * @param persistentInsetTypes the bitmask of any inset types which were handled as part of the + * layout + * @param deferredInsetTypes the bitmask of insets types which should be deferred until after + * any [WindowInsetsAnimationCompat]s have ended + * @param dispatchMode The dispatch mode for this callback. + * See [WindowInsetsAnimationCompat.Callback.getDispatchMode]. + */ + public TranslateDeferringInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes, + final int dispatchMode) { + super(dispatchMode); + if ((persistentInsetTypes & deferredInsetTypes) != 0) { + throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + + "any of same WindowInsetsCompat.Type values"); + } + this.view = view; + this.persistentInsetTypes = persistentInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnimations) { + // onProgress() is called when any of the running animations progress... + + // First we get the insets which are potentially deferred + final Insets typesInset = insets.getInsets(deferredInsetTypes); + // Then we get the persistent inset types which are applied as padding during layout + final Insets otherInset = insets.getInsets(persistentInsetTypes); + + // Now that we subtract the two insets, to calculate the difference. We also coerce + // the insets to be >= 0, to make sure we don't use negative insets. + final Insets subtract = Insets.subtract(typesInset, otherInset); + final Insets diff = Insets.max(subtract, Insets.NONE); + + // The resulting `diff` insets contain the values for us to apply as a translation + // to the view + view.setTranslationX(diff.left - diff.right); + view.setTranslationY(shouldTranslate ? diff.top - diff.bottom : -kbHeight); + + return insets; + } + + @Override + public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { + try { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (kbHeight == 0) { + if (rootWindowInsets == null) return; + final Insets imeInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()); + final Insets navBarInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()); + kbHeight = imeInsets.bottom - navBarInsets.bottom; + } + // Once the animation has ended, reset the translation values + view.setTranslationX(0f); + final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + float translationY = 0; + if (!shouldTranslate) { + translationY = -kbHeight; + if (visible) { + translationY = 0; + } + } + view.setTranslationY(translationY); + } finally { + shouldTranslate = true; + } + } + + public void setShouldTranslate(final boolean shouldTranslate) { + this.shouldTranslate = shouldTranslate; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/VerticalSpaceItemDecoration.java b/app/src/main/java/awais/instagrabber/customviews/helpers/VerticalSpaceItemDecoration.java new file mode 100644 index 0000000..71f7705 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/VerticalSpaceItemDecoration.java @@ -0,0 +1,20 @@ +package awais.instagrabber.customviews.helpers; + +import android.graphics.Rect; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public class VerticalSpaceItemDecoration extends RecyclerView.ItemDecoration { + private final int verticalSpaceHeight; + + public VerticalSpaceItemDecoration(int verticalSpaceHeight) { + this.verticalSpaceHeight = verticalSpaceHeight; + } + + @Override + public void getItemOffsets(Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + outRect.bottom = verticalSpaceHeight; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/VideoAwareRecyclerScroller.java b/app/src/main/java/awais/instagrabber/customviews/helpers/VideoAwareRecyclerScroller.java new file mode 100755 index 0000000..a7ec702 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/VideoAwareRecyclerScroller.java @@ -0,0 +1,334 @@ +package awais.instagrabber.customviews.helpers; + +import android.graphics.Point; +import android.graphics.Rect; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.exoplayer2.SimpleExoPlayer; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.feed.FeedVideoViewHolder; +import awais.instagrabber.repositories.responses.Media; + +import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_DRAGGING; +import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE; + +public class VideoAwareRecyclerScroller extends RecyclerView.OnScrollListener { + private static final String TAG = "VideoAwareRecScroll"; + private static final int FLING_JUMP_LOW_THRESHOLD = 80; + private static final int FLING_JUMP_HIGH_THRESHOLD = 120; + private static final Object LOCK = new Object(); + + private LinearLayoutManager layoutManager; + private boolean dragging; + private boolean isLoadingPaused = false; + private FeedVideoViewHolder currentlyPlayingViewHolder; + + @Override + public void onScrollStateChanged(@NonNull final RecyclerView recyclerView, final int newState) { + dragging = newState == SCROLL_STATE_DRAGGING; + if (isLoadingPaused) { + if (newState == SCROLL_STATE_DRAGGING || newState == SCROLL_STATE_IDLE) { + // user is touchy or the scroll finished, show videos + isLoadingPaused = false; + } // settling means the user let the screen go, but it can still be flinging + } + } + + @Override + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + if (!dragging) { + // TODO can be made better by a rolling average of last N calls to smooth out patterns like a,b,a + int currentSpeed = Math.abs(dy); + if (isLoadingPaused && currentSpeed < FLING_JUMP_LOW_THRESHOLD) { + isLoadingPaused = false; + } else if (!isLoadingPaused && FLING_JUMP_HIGH_THRESHOLD < currentSpeed) { + isLoadingPaused = true; + // stop playing video + } + } + if (isLoadingPaused) return; + if (layoutManager == null) { + final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); + if (layoutManager instanceof LinearLayoutManager) + this.layoutManager = (LinearLayoutManager) layoutManager; + } + if (layoutManager == null) { + return; + } + int firstVisibleItemPos = layoutManager.findFirstCompletelyVisibleItemPosition(); + int lastVisibleItemPos = layoutManager.findLastCompletelyVisibleItemPosition(); + if (firstVisibleItemPos == -1 && lastVisibleItemPos == -1) { + firstVisibleItemPos = layoutManager.findFirstVisibleItemPosition(); + lastVisibleItemPos = layoutManager.findLastVisibleItemPosition(); + } + synchronized (LOCK) { + final FeedVideoViewHolder videoHolder = getFirstVideoHolder(recyclerView, firstVisibleItemPos, lastVisibleItemPos); + if (videoHolder == null || videoHolder.getCurrentFeedModel() == null) { + if (currentlyPlayingViewHolder != null) { + // currentlyPlayingViewHolder.stopPlaying(); + currentlyPlayingViewHolder = null; + } + return; + } + if (currentlyPlayingViewHolder != null && currentlyPlayingViewHolder.getCurrentFeedModel().getPk() + .equals(videoHolder.getCurrentFeedModel().getPk())) { + return; + } + if (currentlyPlayingViewHolder != null) { + // currentlyPlayingViewHolder.stopPlaying(); + } + // videoHolder.startPlaying(); + currentlyPlayingViewHolder = videoHolder; + } + // boolean processFirstItem = false, processLastItem = false; + // View currView; + // if (firstVisibleItemPos != -1) { + // currView = layoutManager.findViewByPosition(firstVisibleItemPos); + // if (currView != null && currView.getId() == R.id.videoHolder) { + // firstItemView = currView; + // // processFirstItem = true; + // } + // } + // if (lastVisibleItemPos != -1) { + // currView = layoutManager.findViewByPosition(lastVisibleItemPos); + // if (currView != null && currView.getId() == R.id.videoHolder) { + // lastItemView = currView; + // // processLastItem = true; + // } + // } + // if (firstItemView == null && lastItemView == null) { + // return; + // } + // if (firstItemView != null) { + // + // Log.d(TAG, "view" + viewHolder); + // } + // if (lastItemView != null) { + // final FeedVideoViewHolder viewHolder = (FeedVideoViewHolder) recyclerView.getChildViewHolder(lastItemView); + // Log.d(TAG, "view" + viewHolder); + // } + // Log.d(TAG, firstItemView + " " + lastItemView); + + // final Rect visibleItemRect = new Rect(); + + // int firstVisibleItemHeight = 0, lastVisibleItemHeight = 0; + + // final boolean isFirstItemVideoHolder = firstItemView != null && firstItemView.getId() == R.id.videoHolder; + // if (isFirstItemVideoHolder) { + // firstItemView.getGlobalVisibleRect(visibleItemRect); + // firstVisibleItemHeight = visibleItemRect.height(); + // } + // final boolean isLastItemVideoHolder = lastItemView != null && lastItemView.getId() == R.id.videoHolder; + // if (isLastItemVideoHolder) { + // lastItemView.getGlobalVisibleRect(visibleItemRect); + // lastVisibleItemHeight = visibleItemRect.height(); + // } + // + // if (processFirstItem && firstVisibleItemHeight > lastVisibleItemHeight) + // videoPosShown = firstVisibleItemPos; + // else if (processLastItem && lastVisibleItemHeight != 0) videoPosShown = lastVisibleItemPos; + // + // if (firstItemView != lastItemView) { + // final int mox = lastVisibleItemHeight - firstVisibleItemHeight; + // if (processLastItem && lastVisibleItemHeight > firstVisibleItemHeight) + // videoPosShown = lastVisibleItemPos; + // if ((processFirstItem || processLastItem) && mox >= 0) + // videoPosShown = lastVisibleItemPos; + // } + // + // if (lastChangedVideoPos != -1 && lastVideoPos != -1) { + // currView = layoutManager.findViewByPosition(lastChangedVideoPos); + // if (currView != null && currView.getId() == R.id.videoHolder && + // lastStoppedVideoPos != lastChangedVideoPos && lastPlayedVideoPos != lastChangedVideoPos) { + // lastStoppedVideoPos = lastChangedVideoPos; + // stopVideo(lastChangedVideoPos, recyclerView, currView); + // } + // + // currView = layoutManager.findViewByPosition(lastVideoPos); + // if (currView != null && currView.getId() == R.id.videoHolder) { + // final Rect rect = new Rect(); + // currView.getGlobalVisibleRect(rect); + // + // final int holderTop = currView.getTop(); + // final int holderHeight = currView.getBottom() - holderTop; + // final int halfHeight = holderHeight / 2; + // //halfHeight -= halfHeight / 5; + // + // if (rect.height() < halfHeight) { + // if (lastStoppedVideoPos != lastVideoPos) { + // lastStoppedVideoPos = lastVideoPos; + // stopVideo(lastVideoPos, recyclerView, currView); + // } + // } else if (lastPlayedVideoPos != lastVideoPos) { + // lastPlayedVideoPos = lastVideoPos; + // playVideo(lastVideoPos, recyclerView, currView); + // } + // } + // + // if (lastChangedVideoPos != lastVideoPos) lastChangedVideoPos = lastVideoPos; + // } + // + // if (lastVideoPos != -1 && lastVideoPos != videoPosShown) { + // if (videoAttached) { + // //if ((currView = layoutManager.findViewByPosition(lastVideoPos)) != null && currView.getId() == R.id.videoHolder) + // releaseVideo(lastVideoPos, recyclerView, null); + // videoAttached = false; + // } + // } + // if (videoPosShown != -1) { + // lastVideoPos = videoPosShown; + // if (!videoAttached) { + // if ((currView = layoutManager.findViewByPosition(videoPosShown)) != null && currView.getId() == R.id.videoHolder) + // attachVideo(videoPosShown, recyclerView, currView); + // videoAttached = true; + // } + // } + } + + private FeedVideoViewHolder getFirstVideoHolder(final RecyclerView recyclerView, final int firstVisibleItemPos, final int lastVisibleItemPos) { + final Rect visibleItemRect = new Rect(); + final Point offset = new Point(); + for (int pos = firstVisibleItemPos; pos <= lastVisibleItemPos; pos++) { + final View view = layoutManager.findViewByPosition(pos); + if (view != null && view.getId() == R.id.videoHolder) { + final View viewSwitcher = view.findViewById(R.id.root); + if (viewSwitcher == null) { + continue; + } + final boolean result = viewSwitcher.getGlobalVisibleRect(visibleItemRect, offset); + if (!result) continue; + final FeedVideoViewHolder viewHolder = (FeedVideoViewHolder) recyclerView.getChildViewHolder(view); + final Media currentFeedModel = viewHolder.getCurrentFeedModel(); + visibleItemRect.offset(-offset.x, -offset.y); + final int visibleHeight = visibleItemRect.height(); + if (visibleHeight < currentFeedModel.getOriginalHeight()) { + continue; + } + // Log.d(TAG, "post:" + currentFeedModel.getPostId() + ", visibleHeight: " + visibleHeight + ", post height: " + currentFeedModel.getImageHeight()); + return viewHolder; + } + } + return null; + } + + public void startPlaying() { + if (currentlyPlayingViewHolder == null) { + return; + } + // currentlyPlayingViewHolder.startPlaying(); + } + + public void stopPlaying() { + if (currentlyPlayingViewHolder == null) { + return; + } + // currentlyPlayingViewHolder.stopPlaying(); + } + + // private synchronized void attachVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { + // synchronized (LOCK) { + // if (recyclerView != null) { + // final RecyclerView.Adapter adapter = recyclerView.getAdapter(); + // if (adapter instanceof FeedAdapter) { + // final SimpleExoPlayer pagerPlayer = ((FeedAdapter) adapter).pagerPlayer; + // if (pagerPlayer != null) pagerPlayer.setPlayWhenReady(false); + // } + // } + // if (itemView == null) { + // return; + // } + // final boolean shouldAutoplay = settingsHelper.getBoolean(Constants.AUTOPLAY_VIDEOS); + // final FeedModel feedModel = feedModels.get(itemPos); + // // loadVideo(itemPos, itemView, shouldAutoplay, feedModel); + // } + // } + // + // private void loadVideo(final int itemPos, final View itemView, final boolean shouldAutoplay, final FeedModel feedModel) { + // final PlayerView playerView = itemView.findViewById(R.id.playerView); + // if (playerView == null) { + // return; + // } + // if (player != null) { + // player.stop(true); + // player.release(); + // player = null; + // } + // + // player = new SimpleExoPlayer.Builder(context) + // .setUseLazyPreparation(!shouldAutoplay) + // .build(); + // player.setPlayWhenReady(shouldAutoplay); + // + // final View btnComments = itemView.findViewById(R.id.btnComments); + // if (btnComments != null) { + // if (feedModel.getCommentsCount() <= 0) btnComments.setEnabled(false); + // else { + // btnComments.setTag(feedModel); + // btnComments.setEnabled(true); + // btnComments.setOnClickListener(commentClickListener); + // } + // } + // playerView.setPlayer(player); + // btnMute = itemView.findViewById(R.id.btnMute); + // float vol = settingsHelper.getBoolean(Constants.MUTED_VIDEOS) ? 0f : 1f; + // if (vol == 0f && Utils.sessionVolumeFull) vol = 1f; + // player.setVolume(vol); + // + // if (btnMute != null) { + // btnMute.setVisibility(View.VISIBLE); + // btnMute.setImageResource(vol == 0f ? R.drawable.vol : R.drawable.mute); + // btnMute.setOnClickListener(muteClickListener); + // } + // final DataSource.Factory factory = cacheDataSourceFactory != null ? cacheDataSourceFactory : dataSourceFactory; + // final ProgressiveMediaSource.Factory sourceFactory = new ProgressiveMediaSource.Factory(factory); + // final ProgressiveMediaSource mediaSource = sourceFactory.createMediaSource(Uri.parse(feedModel.getDisplayUrl())); + // + // player.setRepeatMode(Player.REPEAT_MODE_ALL); + // player.prepare(mediaSource); + // player.setVolume(vol); + // + // playerView.setOnClickListener(v -> player.setPlayWhenReady(!player.getPlayWhenReady())); + // + // if (videoChangeCallback != null) videoChangeCallback.playerChanged(itemPos, player); + // } + // + // private void releaseVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { + // // Log.d("AWAISKING_APP", "release: " + itemPos); + // // if (player != null) { + // // player.stop(true); + // // player.release(); + // // } + // // player = null; + // } + // + // private void playVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { + // // if (player != null) { + // // final int playbackState = player.getPlaybackState(); + // // if (!player.isPlaying() + // // || playbackState == Player.STATE_READY || playbackState == Player.STATE_ENDED + // // ) { + // // player.setPlayWhenReady(true); + // // } + // // } + // // if (player != null) { + // // player.setPlayWhenReady(true); + // // player.getPlaybackState(); + // // } + // } + // + // private void stopVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { + // if (player != null) { + // player.setPlayWhenReady(false); + // player.getPlaybackState(); + // } + // } + + public interface VideoChangeCallback { + void playerChanged(final int itemPos, final SimpleExoPlayer player); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/SoundParser.java b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/SoundParser.java new file mode 100755 index 0000000..6fb309d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/SoundParser.java @@ -0,0 +1,252 @@ +package awais.instagrabber.customviews.masoudss_waveform; + +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.nfc.FormatException; +import android.os.Build; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.ShortBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class SoundParser { + private ProgressListener progressListener; + int[] frameGains; + ////////////////// + private static String[] supportedExtensions = {"mp3", "wav", "3gpp", "3gp", "amr", "aac", "m4a", "ogg"}; + private static ArrayList additionalExtensions = new ArrayList<>(); + + static void addCustomExtension(final String extension) { + additionalExtensions.add(extension); + } + + static void removeCustomExtension(final String extension) { + additionalExtensions.remove(extension); + } + + static void addCustomExtensions(final List extensions) { + additionalExtensions.addAll(extensions); + } + + static void removeCustomExtensions(final List extensions) { + additionalExtensions.removeAll(extensions); + } + + private static boolean isFilenameSupported(final String filename) { + for (final String supportedExtension : supportedExtensions) + if (filename.endsWith('.' + supportedExtension)) return true; + for (final String additionalExtension : additionalExtensions) + if (filename.endsWith('.' + additionalExtension)) return true; + return false; + } + + @NonNull + public static SoundParser create(final String fileName, final boolean ignoreExtension) throws IOException, FormatException { + if (!ignoreExtension && !isFilenameSupported(fileName)) + throw new FormatException("Not supported file extension."); + + final File f = new File(fileName); + if (!f.exists()) throw new FileNotFoundException(fileName); + + final SoundParser soundFile = new SoundParser(); + soundFile.readFile(f); + + return soundFile; + } + + public void setProgressListener(final ProgressListener progressListener) { + this.progressListener = progressListener; + } + + @SuppressWarnings("deprecation") + private void readFile(@NonNull final File inputFile) throws IOException, FormatException { + final MediaExtractor extractor = new MediaExtractor(); + MediaFormat format = null; + + final int fileSizeBytes = (int) inputFile.length(); + extractor.setDataSource(inputFile.getPath()); + + final int numTracks = extractor.getTrackCount(); + + int i = 0; + while (i < numTracks) { + format = extractor.getTrackFormat(i); + if (Objects.requireNonNull(format.getString(MediaFormat.KEY_MIME)).startsWith("audio/")) { + extractor.selectTrack(i); + break; + } + i++; + } + + if (i == numTracks) throw new FormatException("No audio track found in " + inputFile); + assert format != null; + + final int channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + final int sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE); + + final int expectedNumSamples = (int) (format.getLong(MediaFormat.KEY_DURATION) / 1000000f * sampleRate + 0.5f); + + final MediaCodec codec = MediaCodec.createDecoderByType(Objects.requireNonNull(format.getString(MediaFormat.KEY_MIME))); + codec.configure(format, null, null, 0); + codec.start(); + + final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + final ByteBuffer[] inputBuffers = codec.getInputBuffers(); + + boolean firstSampleData = true, doneReading = false; + long presentationTime; + int sampleSize, decodedSamplesSize = 0, totSizeRead = 0; + byte[] decodedSamples = null; + ByteBuffer mDecodedBytes = ByteBuffer.allocate(1 << 20); + ByteBuffer[] outputBuffers = codec.getOutputBuffers(); + + while (true) { + final int inputBufferIndex = codec.dequeueInputBuffer(100); + + if (!doneReading && inputBufferIndex >= 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + sampleSize = extractor.readSampleData(Objects.requireNonNull(codec.getInputBuffer(inputBufferIndex)), 0); + else + sampleSize = extractor.readSampleData(inputBuffers[inputBufferIndex], 0); + + if (firstSampleData && sampleSize == 2 && "audio/mp4a-latm".equals(format.getString(MediaFormat.KEY_MIME))) { + extractor.advance(); + totSizeRead += sampleSize; + } else if (sampleSize < 0) { + codec.queueInputBuffer(inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + doneReading = true; + } else { + presentationTime = extractor.getSampleTime(); + codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0); + extractor.advance(); + totSizeRead += sampleSize; + + if (progressListener != null && !progressListener.reportProgress((double) totSizeRead / fileSizeBytes)) { + // We are asked to stop reading the file. Returning immediately. + // The SoundFile object is invalid and should NOT be used afterward! + extractor.release(); + codec.stop(); + codec.release(); + return; + } + } + + firstSampleData = false; + } + + // Get decoded stream from the decoder output buffers. + final int outputBufferIndex = codec.dequeueOutputBuffer(info, 100); + if (outputBufferIndex >= 0 && info.size > 0) { + if (decodedSamplesSize < info.size) { + decodedSamplesSize = info.size; + decodedSamples = new byte[decodedSamplesSize]; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferIndex); + assert outputBuffer != null; + outputBuffer.get(decodedSamples, 0, info.size); + outputBuffer.clear(); + } else { + outputBuffers[outputBufferIndex].get(decodedSamples, 0, info.size); + outputBuffers[outputBufferIndex].clear(); + } + + // Check if buffer is big enough. Resize it if it's too small. + if (mDecodedBytes.remaining() < info.size) { + // Getting a rough estimate of the total size, allocate 20% more, and + // make sure to allocate at least 5MB more than the initial size. + final int position = mDecodedBytes.position(); + + int newSize = (int) (position * (1.0 * fileSizeBytes / totSizeRead) * 1.2); + final int infoSize = info.size + 5 * (1 << 20); + if (newSize - position < infoSize) + newSize = position + infoSize; + + ByteBuffer newDecodedBytes = null; + + // Try to allocate memory. If we are OOM, try to run the garbage collector. + int retry = 10; + while (retry > 0) { + try { + newDecodedBytes = ByteBuffer.allocate(newSize); + break; + } catch (final OutOfMemoryError e) { + retry--; + } + } + if (retry == 0) break; + mDecodedBytes.rewind(); + assert newDecodedBytes != null; + newDecodedBytes.put(mDecodedBytes); + mDecodedBytes = newDecodedBytes; + mDecodedBytes.position(position); + } + + mDecodedBytes.put(decodedSamples, 0, info.size); + codec.releaseOutputBuffer(outputBufferIndex, false); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) + outputBuffers = codec.getOutputBuffers(); + } + + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 || mDecodedBytes.position() / (2 * channels) >= expectedNumSamples) + break; + } + + final int numSamples = mDecodedBytes.position() / (channels * 2); // One sample = 2 bytes. + mDecodedBytes.rewind(); + mDecodedBytes.order(ByteOrder.LITTLE_ENDIAN); + final ShortBuffer mDecodedSamples = mDecodedBytes.asShortBuffer(); + // final int avgBitrateKbps = (int) (fileSizeBytes * 8F * ((float) sampleRate / numSamples) / 1000F); + + extractor.release(); + codec.stop(); + codec.release(); + + final int samplesPerFrame = 1024; + int numFrames = numSamples / samplesPerFrame; + if (numSamples % samplesPerFrame != 0) numFrames++; + frameGains = new int[numFrames]; + // final int[] mFrameLens = new int[numFrames]; + // final int[] mFrameOffsets = new int[numFrames]; + // final int frameLens = (int) (1000F * avgBitrateKbps / 8F * ((float) samplesPerFrame / sampleRate)); + int j, gain, value; + + i = 0; + while (i < numFrames) { + gain = -1; + j = 0; + + while (j < samplesPerFrame) { + value = 0; + for (int k = 0; k < channels; ++k) + if (mDecodedSamples.remaining() > 0) + value += Math.abs(mDecodedSamples.get()); + value /= channels; + if (gain < value) gain = value; + j++; + } + + frameGains[i] = (int) Math.sqrt(gain); + // mFrameLens[i] = frameLens; + // mFrameOffsets[i] = (int) ((float) i * (1000F * avgBitrateKbps / 8F) * ((float) samplesPerFrame / sampleRate)); + i++; + } + + mDecodedSamples.rewind(); + } + + private interface ProgressListener { + boolean reportProgress(final double fractionComplete); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveFormProgressChangeListener.java b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveFormProgressChangeListener.java new file mode 100755 index 0000000..326bc10 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveFormProgressChangeListener.java @@ -0,0 +1,5 @@ +package awais.instagrabber.customviews.masoudss_waveform; + +public interface WaveFormProgressChangeListener { + void onProgressChanged(final WaveformSeekBar waveformSeekBar, final int progress, final boolean fromUser); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveGravity.java b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveGravity.java new file mode 100755 index 0000000..5ac68de --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveGravity.java @@ -0,0 +1,7 @@ +package awais.instagrabber.customviews.masoudss_waveform; + +public enum WaveGravity { + TOP, + CENTER, + BOTTOM, +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveformSeekBar.java b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveformSeekBar.java new file mode 100755 index 0000000..a2054df --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveformSeekBar.java @@ -0,0 +1,245 @@ +package awais.instagrabber.customviews.masoudss_waveform; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import awais.instagrabber.R; +import awais.instagrabber.utils.CubicInterpolation; +import awais.instagrabber.utils.Utils; + +public final class WaveformSeekBar extends View { + private final int mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); + private final Paint mWavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF mWaveRect = new RectF(); + private final Canvas mProgressCanvas = new Canvas(); + private final WaveGravity waveGravity = WaveGravity.CENTER; + private final int waveBackgroundColor; + private final int waveProgressColor; + private final float waveWidth = Utils.convertDpToPx(3); + private final float waveMinHeight = Utils.convertDpToPx(4); + private final float waveCornerRadius = Utils.convertDpToPx(2); + private final float waveGap = Utils.convertDpToPx(1); + // private int mCanvasWidth = 0; + // private int mCanvasHeight = 0; + private float mTouchDownX = 0F; + private float[] sample; + private int progress = 0; + private WaveFormProgressChangeListener progressChangeListener; + private int wavesCount; + private CubicInterpolation interpolation; + + public WaveformSeekBar(final Context context) { + this(context, null); + } + + public WaveformSeekBar(final Context context, @Nullable final AttributeSet attrs) { + this(context, attrs, 0); + } + + public WaveformSeekBar(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + final TypedArray a = context.getTheme().obtainStyledAttributes( + attrs, + R.styleable.WaveformSeekBar, + 0, + 0); + final int backgroundColor; + final int progressColor; + try { + backgroundColor = a.getResourceId(R.styleable.WaveformSeekBar_waveformBackgroundColor, R.color.white); + progressColor = a.getResourceId(R.styleable.WaveformSeekBar_waveformProgressColor, R.color.blue_800); + } finally { + a.recycle(); + } + this.waveBackgroundColor = context.getResources().getColor(backgroundColor); + this.waveProgressColor = context.getResources().getColor(progressColor); + } + + private float getSampleMax() { + float max = -1f; + if (sample != null) { + for (final float v : sample) { + if (v > max) max = v; + } + } + return max; + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + if (sample == null || sample.length == 0) return; + final int availableWidth = getAvailableWidth(); + final int availableHeight = getAvailableHeight(); + + // final float step = availableWidth / (waveGap + waveWidth) / sample.size(); + + int i = 0; + float lastWaveRight = (float) getPaddingLeft(); + + final float sampleMax = getSampleMax(); + while (i < wavesCount) { + final float t = lastWaveRight / availableWidth * sample.length; + float waveHeight = availableHeight * (interpolation.interpolate(t) / sampleMax); + + if (waveHeight < waveMinHeight) + waveHeight = waveMinHeight; + + final float top; + if (waveGravity == WaveGravity.TOP) { + top = (float) getPaddingTop(); + } else if (waveGravity == WaveGravity.CENTER) { + top = (float) getPaddingTop() + availableHeight / 2F - waveHeight / 2F; + } else if (waveGravity == WaveGravity.BOTTOM) { + top = getMeasuredHeight() - (float) getPaddingBottom() - waveHeight; + } else { + top = 0; + } + + mWaveRect.set(lastWaveRight, top, lastWaveRight + waveWidth, top + waveHeight); + + if (mWaveRect.contains(availableWidth * progress / 100F, mWaveRect.centerY())) { + int bitHeight = (int) mWaveRect.height(); + if (bitHeight <= 0) bitHeight = (int) waveWidth; + + final Bitmap bitmap = Bitmap.createBitmap(availableWidth, bitHeight, Bitmap.Config.ARGB_8888); + mProgressCanvas.setBitmap(bitmap); + + float fillWidth = availableWidth * progress / 100F; + + mWavePaint.setColor(waveProgressColor); + mProgressCanvas.drawRect(0F, 0F, fillWidth, mWaveRect.bottom, mWavePaint); + + mWavePaint.setColor(waveBackgroundColor); + mProgressCanvas.drawRect(fillWidth, 0F, (float) availableWidth, mWaveRect.bottom, mWavePaint); + + mWavePaint.setShader(new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)); + } else { + mWavePaint.setColor(mWaveRect.right <= availableWidth * progress / 100F ? waveProgressColor : waveBackgroundColor); + mWavePaint.setShader(null); + } + + canvas.drawRoundRect(mWaveRect, waveCornerRadius, waveCornerRadius, mWavePaint); + + lastWaveRight = mWaveRect.right + waveGap; + + if (lastWaveRight + waveWidth > availableWidth + getPaddingLeft()) { + break; + } + i++; + } + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + if (!isEnabled()) return false; + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + if (isParentScrolling()) mTouchDownX = event.getX(); + else updateProgress(event); + break; + + case MotionEvent.ACTION_MOVE: + updateProgress(event); + break; + + case MotionEvent.ACTION_UP: + if (Math.abs(event.getX() - mTouchDownX) > mScaledTouchSlop) + updateProgress(event); + + performClick(); + break; + } + + return true; + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed) { + calculateWaveDimensions(); + } + } + + private void calculateWaveDimensions() { + if (sample == null || sample.length == 0) return; + final int availableWidth = getAvailableWidth(); + wavesCount = (int) (availableWidth / (waveGap + waveWidth)); + interpolation = new CubicInterpolation(sample); + } + + // @Override + // protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { + // super.onSizeChanged(w, h, oldw, oldh); + // mCanvasWidth = w; + // mCanvasHeight = h; + // } + + @Override + public boolean performClick() { + super.performClick(); + return true; + } + + private boolean isParentScrolling() { + View parent = (View) getParent(); + final View root = getRootView(); + + while (true) { + if (parent.canScrollHorizontally(1) || parent.canScrollHorizontally(-1) || + parent.canScrollVertically(1) || parent.canScrollVertically(-1)) + return true; + + if (parent == root) return false; + + parent = (View) parent.getParent(); + } + } + + private void updateProgress(@NonNull final MotionEvent event) { + progress = (int) (100 * event.getX() / getAvailableWidth()); + invalidate(); + + if (progressChangeListener != null) + progressChangeListener.onProgressChanged(this, Math.min(Math.max(0, progress), 100), true); + } + + private int getAvailableWidth() { + return getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); + } + + private int getAvailableHeight() { + return getMeasuredHeight() - getPaddingTop() - getPaddingBottom(); + } + + public void setProgress(final int progress) { + this.progress = progress; + invalidate(); + } + + public void setProgressChangeListener(final WaveFormProgressChangeListener progressChangeListener) { + this.progressChangeListener = progressChangeListener; + } + + public void setSample(final float[] sample) { + if (sample == this.sample) return; + this.sample = sample; + calculateWaveDimensions(); + invalidate(); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/AppDatabase.kt b/app/src/main/java/awais/instagrabber/db/AppDatabase.kt new file mode 100644 index 0000000..3ba02b1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/AppDatabase.kt @@ -0,0 +1,256 @@ +package awais.instagrabber.db + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.util.Log +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import awais.instagrabber.db.dao.AccountDao +import awais.instagrabber.db.dao.DMLastNotifiedDao +import awais.instagrabber.db.dao.FavoriteDao +import awais.instagrabber.db.dao.RecentSearchDao +import awais.instagrabber.db.entities.Account +import awais.instagrabber.db.entities.DMLastNotified +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.db.entities.RecentSearch +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.TAG +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@Database(entities = [Account::class, Favorite::class, DMLastNotified::class, RecentSearch::class], version = 6) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun accountDao(): AccountDao + abstract fun favoriteDao(): FavoriteDao + abstract fun dmLastNotifiedDao(): DMLastNotifiedDao + abstract fun recentSearchDao(): RecentSearchDao + + companion object { + private lateinit var INSTANCE: AppDatabase + + fun getDatabase(context: Context): AppDatabase { + if (!this::INSTANCE.isInitialized) { + synchronized(AppDatabase::class.java) { + if (!this::INSTANCE.isInitialized) { + INSTANCE = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "cookiebox.db") + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + .build() + } + } + } + return INSTANCE + } + + private val MIGRATION_1_2: Migration = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE cookies ADD " + Account.COL_FULL_NAME + " TEXT") + db.execSQL("ALTER TABLE cookies ADD " + Account.COL_PROFILE_PIC + " TEXT") + } + } + private val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + val oldFavorites = backupOldFavorites(db) + // recreate with new columns (as there will be no doubt about the `query_display` column being present or not in the future versions) + db.execSQL("DROP TABLE " + Favorite.TABLE_NAME) + db.execSQL("CREATE TABLE " + Favorite.TABLE_NAME + " (" + + Favorite.COL_ID + " INTEGER PRIMARY KEY," + + Favorite.COL_QUERY + " TEXT," + + Favorite.COL_TYPE + " TEXT," + + Favorite.COL_DISPLAY_NAME + " TEXT," + + Favorite.COL_PIC_URL + " TEXT," + + Favorite.COL_DATE_ADDED + " INTEGER)") + // add the old favorites back + for (oldFavorite in oldFavorites) { + insertOrUpdateFavorite(db, oldFavorite) + } + } + } + private val MIGRATION_3_4: Migration = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Required when migrating to Room. + // The original table primary keys were not 'NOT NULL', so the migration to Room were failing without the below migration. + // Taking this opportunity to rename cookies table to accounts + + // Create new table with name 'accounts' + db.execSQL("CREATE TABLE " + Account.TABLE_NAME + " (" + + Account.COL_ID + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + Account.COL_UID + " TEXT," + + Account.COL_USERNAME + " TEXT," + + Account.COL_COOKIE + " TEXT," + + Account.COL_FULL_NAME + " TEXT," + + Account.COL_PROFILE_PIC + " TEXT)") + // Insert all data from table 'cookies' to 'accounts' + db.execSQL("INSERT INTO " + Account.TABLE_NAME + " (" + + Account.COL_UID + "," + + Account.COL_USERNAME + "," + + Account.COL_COOKIE + "," + + Account.COL_FULL_NAME + "," + + Account.COL_PROFILE_PIC + ") " + + "SELECT " + + Account.COL_UID + "," + + Account.COL_USERNAME + "," + + Account.COL_COOKIE + "," + + Account.COL_FULL_NAME + "," + + Account.COL_PROFILE_PIC + + " FROM cookies") + // Drop old cookies table + db.execSQL("DROP TABLE cookies") + + // Create favorite backup table + db.execSQL("CREATE TABLE " + Favorite.TABLE_NAME + "_backup (" + + Favorite.COL_ID + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + Favorite.COL_QUERY + " TEXT," + + Favorite.COL_TYPE + " TEXT," + + Favorite.COL_DISPLAY_NAME + " TEXT," + + Favorite.COL_PIC_URL + " TEXT," + + Favorite.COL_DATE_ADDED + " INTEGER)") + // Insert all data from table 'favorite' to 'favorite_backup' + db.execSQL("INSERT INTO " + Favorite.TABLE_NAME + "_backup (" + + Favorite.COL_QUERY + "," + + Favorite.COL_TYPE + "," + + Favorite.COL_DISPLAY_NAME + "," + + Favorite.COL_PIC_URL + "," + + Favorite.COL_DATE_ADDED + ") " + + "SELECT " + + Favorite.COL_QUERY + "," + + Favorite.COL_TYPE + "," + + Favorite.COL_DISPLAY_NAME + "," + + Favorite.COL_PIC_URL + "," + + Favorite.COL_DATE_ADDED + + " FROM " + Favorite.TABLE_NAME) + // Drop favorites + db.execSQL("DROP TABLE " + Favorite.TABLE_NAME) + // Rename favorite_backup to favorites + db.execSQL("ALTER TABLE " + Favorite.TABLE_NAME + "_backup RENAME TO " + Favorite.TABLE_NAME) + } + } + + @JvmField + val MIGRATION_4_5: Migration = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `dm_last_notified` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`thread_id` TEXT, " + + "`last_notified_msg_ts` INTEGER, " + + "`last_notified_at` INTEGER)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `dm_last_notified` (`thread_id`)") + } + } + + @JvmField + val MIGRATION_5_6: Migration = object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `recent_searches` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`ig_id` TEXT NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`username` TEXT, " + + "`pic_url` TEXT, " + + "`type` TEXT NOT NULL, " + + "`last_searched_on` INTEGER NOT NULL)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_searches_ig_id_type` ON `recent_searches` (`ig_id`, `type`)") + } + } + + private fun backupOldFavorites(db: SupportSQLiteDatabase): List { + // check if old favorites table had the column query_display + val queryDisplayExists = checkColumnExists(db, Favorite.TABLE_NAME, "query_display") + Log.d(TAG, "backupOldFavorites: queryDisplayExists: $queryDisplayExists") + val oldModels: MutableList = ArrayList() + val sql = ("SELECT " + + "query_text," + + "date_added" + + (if (queryDisplayExists) ",query_display" else "") + + " FROM " + Favorite.TABLE_NAME) + try { + db.query(sql).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + do { + try { + val queryText = cursor.getString(cursor.getColumnIndex("query_text")) + val favoriteTypeQueryPair = Utils.migrateOldFavQuery(queryText) ?: continue + val type = favoriteTypeQueryPair.first + val query = favoriteTypeQueryPair.second + val epochMillis = cursor.getLong(cursor.getColumnIndex("date_added")) + val localDateTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(epochMillis), + ZoneId.systemDefault() + ) + oldModels.add(Favorite( + 0, + query, + type, + if (queryDisplayExists) cursor.getString(cursor.getColumnIndex("query_display")) else null, + null, + localDateTime + )) + } catch (e: Exception) { + Log.e(TAG, "onUpgrade", e) + } + } while (cursor.moveToNext()) + } + } + } catch (e: Exception) { + Log.e(TAG, "onUpgrade", e) + } + Log.d(TAG, "backupOldFavorites: oldModels:$oldModels") + return oldModels + } + + @Synchronized + private fun insertOrUpdateFavorite(db: SupportSQLiteDatabase, model: Favorite) { + val values = ContentValues() + values.put(Favorite.COL_QUERY, model.query) + values.put(Favorite.COL_TYPE, model.type.toString()) + values.put(Favorite.COL_DISPLAY_NAME, model.displayName) + values.put(Favorite.COL_PIC_URL, model.picUrl) + values.put(Favorite.COL_DATE_ADDED, model.dateAdded!!.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()) + val rows: Int = if (model.id >= 1) { + db.update(Favorite.TABLE_NAME, + SQLiteDatabase.CONFLICT_IGNORE, + values, + Favorite.COL_ID + "=?", arrayOf(model.id.toString())) + } else { + db.update(Favorite.TABLE_NAME, + SQLiteDatabase.CONFLICT_IGNORE, + values, + Favorite.COL_QUERY + "=?" + " AND " + Favorite.COL_TYPE + "=?", arrayOf(model.query, model.type.toString())) + } + if (rows != 1) { + db.insert(Favorite.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values) + } + } + + @Suppress("SameParameterValue") + private fun checkColumnExists( + db: SupportSQLiteDatabase, + tableName: String, + columnName: String, + ): Boolean { + var exists = false + try { + db.query("PRAGMA table_info($tableName)").use { cursor -> + if (cursor.moveToFirst()) { + do { + val currentColumn = cursor.getString(cursor.getColumnIndex("name")) + if (currentColumn == columnName) { + exists = true + } + } while (cursor.moveToNext()) + } + } + } catch (ex: Exception) { + Log.e(TAG, "checkColumnExists", ex) + } + return exists + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/Converters.kt b/app/src/main/java/awais/instagrabber/db/Converters.kt new file mode 100644 index 0000000..2e82e10 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/Converters.kt @@ -0,0 +1,29 @@ +package awais.instagrabber.db + +import androidx.room.TypeConverter +import awais.instagrabber.models.enums.FavoriteType +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset + +class Converters { + @TypeConverter + fun fromFavoriteTypeString(value: String?): FavoriteType? = + if (value == null) null + else try { + FavoriteType.valueOf(value) + } catch (e: Exception) { + null + } + + @TypeConverter + fun favoriteTypeToString(favoriteType: FavoriteType?): String? = favoriteType?.toString() + + @TypeConverter + fun fromTimestampToLocalDateTime(value: Long?): LocalDateTime? = + if (value == null) null else LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.systemDefault()) + + @TypeConverter + fun localDateTimeToTimestamp(localDateTime: LocalDateTime?): Long? = localDateTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/dao/AccountDao.kt b/app/src/main/java/awais/instagrabber/db/dao/AccountDao.kt new file mode 100644 index 0000000..5661de8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/dao/AccountDao.kt @@ -0,0 +1,25 @@ +package awais.instagrabber.db.dao + +import androidx.room.* +import awais.instagrabber.db.entities.Account + +@Dao +interface AccountDao { + @Query("SELECT * FROM accounts") + suspend fun getAllAccounts(): List + + @Query("SELECT * FROM accounts WHERE uid = :uid") + suspend fun findAccountByUid(uid: String): Account? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAccounts(vararg accounts: Account) + + @Update + suspend fun updateAccounts(vararg accounts: Account) + + @Delete + suspend fun deleteAccounts(vararg accounts: Account) + + @Query("DELETE from accounts") + suspend fun deleteAllAccounts() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/dao/DMLastNotifiedDao.kt b/app/src/main/java/awais/instagrabber/db/dao/DMLastNotifiedDao.kt new file mode 100644 index 0000000..50f6f37 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/dao/DMLastNotifiedDao.kt @@ -0,0 +1,25 @@ +package awais.instagrabber.db.dao + +import androidx.room.* +import awais.instagrabber.db.entities.DMLastNotified + +@Dao +interface DMLastNotifiedDao { + @Query("SELECT * FROM dm_last_notified") + suspend fun getAllDMDmLastNotified(): List + + @Query("SELECT * FROM dm_last_notified WHERE thread_id = :threadId") + suspend fun findDMLastNotifiedByThreadId(threadId: String): DMLastNotified? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDMLastNotified(vararg dmLastNotified: DMLastNotified) + + @Update + suspend fun updateDMLastNotified(vararg dmLastNotified: DMLastNotified) + + @Delete + suspend fun deleteDMLastNotified(vararg dmLastNotified: DMLastNotified) + + @Query("DELETE from dm_last_notified") + suspend fun deleteAllDMLastNotified() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/dao/FavoriteDao.kt b/app/src/main/java/awais/instagrabber/db/dao/FavoriteDao.kt new file mode 100644 index 0000000..f78e531 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/dao/FavoriteDao.kt @@ -0,0 +1,26 @@ +package awais.instagrabber.db.dao + +import androidx.room.* +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.models.enums.FavoriteType + +@Dao +interface FavoriteDao { + @Query("SELECT * FROM favorites") + suspend fun getAllFavorites(): List + + @Query("SELECT * FROM favorites WHERE query_text = :query and type = :type") + suspend fun findFavoriteByQueryAndType(query: String, type: FavoriteType): Favorite? + + @Insert + suspend fun insertFavorites(vararg favorites: Favorite) + + @Update + suspend fun updateFavorites(vararg favorites: Favorite) + + @Delete + suspend fun deleteFavorites(vararg favorites: Favorite) + + @Query("DELETE from favorites") + suspend fun deleteAllFavorites() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.kt b/app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.kt new file mode 100644 index 0000000..b4ed3d4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.kt @@ -0,0 +1,29 @@ +package awais.instagrabber.db.dao + +import androidx.room.* +import awais.instagrabber.db.entities.RecentSearch +import awais.instagrabber.models.enums.FavoriteType + +@Dao +interface RecentSearchDao { + @Query("SELECT * FROM recent_searches ORDER BY last_searched_on DESC") + suspend fun getAllRecentSearches(): List + + @Query("SELECT * FROM recent_searches WHERE `ig_id` = :igId AND `type` = :type") + suspend fun getRecentSearchByIgIdAndType(igId: String, type: FavoriteType): RecentSearch? + + @Query("SELECT * FROM recent_searches WHERE instr(`name`, :query) > 0") + suspend fun findRecentSearchesWithNameContaining(query: String): List + + @Insert + suspend fun insertRecentSearch(recentSearch: RecentSearch) + + @Update + suspend fun updateRecentSearch(recentSearch: RecentSearch) + + @Delete + suspend fun deleteRecentSearch(recentSearch: RecentSearch) + + // @Query("DELETE from recent_searches") + // void deleteAllRecentSearches(); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/datasources/AccountDataSource.kt b/app/src/main/java/awais/instagrabber/db/datasources/AccountDataSource.kt new file mode 100644 index 0000000..ecc5f56 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/datasources/AccountDataSource.kt @@ -0,0 +1,44 @@ +package awais.instagrabber.db.datasources + +import android.content.Context +import awais.instagrabber.db.AppDatabase +import awais.instagrabber.db.dao.AccountDao +import awais.instagrabber.db.entities.Account + +class AccountDataSource(private val accountDao: AccountDao) { + suspend fun getAccount(uid: String): Account? = accountDao.findAccountByUid(uid) + + suspend fun getAllAccounts(): List = accountDao.getAllAccounts() + + suspend fun insertOrUpdateAccount( + uid: String?, + username: String?, + cookie: String?, + fullName: String?, + profilePicUrl: String?, + ) { + val account = uid?.let { getAccount(it) } + val toUpdate = Account(account?.id ?: 0, uid, username, cookie, fullName, profilePicUrl) + if (account != null) { + accountDao.updateAccounts(toUpdate) + return + } + accountDao.insertAccounts(toUpdate) + } + + suspend fun deleteAccount(account: Account) = accountDao.deleteAccounts(account) + + suspend fun deleteAllAccounts() = accountDao.deleteAllAccounts() + + companion object { + @Volatile + private var INSTANCE: AccountDataSource? = null + + fun getInstance(context: Context): AccountDataSource { + return INSTANCE ?: synchronized(this) { + val dao: AccountDao = AppDatabase.getDatabase(context).accountDao() + AccountDataSource(dao).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/datasources/DMLastNotifiedDataSource.kt b/app/src/main/java/awais/instagrabber/db/datasources/DMLastNotifiedDataSource.kt new file mode 100644 index 0000000..879a0d4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/datasources/DMLastNotifiedDataSource.kt @@ -0,0 +1,54 @@ +package awais.instagrabber.db.datasources + +import android.content.Context +import awais.instagrabber.db.AppDatabase +import awais.instagrabber.db.dao.DMLastNotifiedDao +import awais.instagrabber.db.entities.DMLastNotified +import java.time.LocalDateTime + +class DMLastNotifiedDataSource private constructor(private val dmLastNotifiedDao: DMLastNotifiedDao) { + suspend fun getDMLastNotified(threadId: String): DMLastNotified? = dmLastNotifiedDao.findDMLastNotifiedByThreadId(threadId) + + suspend fun getAllDMDmLastNotified(): List = dmLastNotifiedDao.getAllDMDmLastNotified() + + suspend fun insertOrUpdateDMLastNotified( + threadId: String?, + lastNotifiedMsgTs: LocalDateTime?, + lastNotifiedAt: LocalDateTime?, + ) { + if (threadId == null) return + val dmLastNotified = getDMLastNotified(threadId) + val toUpdate = DMLastNotified( + dmLastNotified?.id ?: 0, + threadId, + lastNotifiedMsgTs, + lastNotifiedAt + ) + if (dmLastNotified != null) { + dmLastNotifiedDao.updateDMLastNotified(toUpdate) + return + } + dmLastNotifiedDao.insertDMLastNotified(toUpdate) + } + + suspend fun deleteDMLastNotified(dmLastNotified: DMLastNotified) = dmLastNotifiedDao.deleteDMLastNotified(dmLastNotified) + + suspend fun deleteAllDMLastNotified() = dmLastNotifiedDao.deleteAllDMLastNotified() + + companion object { + private lateinit var INSTANCE: DMLastNotifiedDataSource + + @JvmStatic + fun getInstance(context: Context): DMLastNotifiedDataSource { + if (!this::INSTANCE.isInitialized) { + synchronized(DMLastNotifiedDataSource::class.java) { + if (!this::INSTANCE.isInitialized) { + val database = AppDatabase.getDatabase(context) + INSTANCE = DMLastNotifiedDataSource(database.dmLastNotifiedDao()) + } + } + } + return INSTANCE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/datasources/FavoriteDataSource.kt b/app/src/main/java/awais/instagrabber/db/datasources/FavoriteDataSource.kt new file mode 100644 index 0000000..7b01dc7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/datasources/FavoriteDataSource.kt @@ -0,0 +1,39 @@ +package awais.instagrabber.db.datasources + +import android.content.Context +import awais.instagrabber.db.AppDatabase +import awais.instagrabber.db.dao.FavoriteDao +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.models.enums.FavoriteType + +class FavoriteDataSource(private val favoriteDao: FavoriteDao) { + suspend fun getFavorite(query: String, type: FavoriteType): Favorite? = favoriteDao.findFavoriteByQueryAndType(query, type) + + suspend fun getAllFavorites(): List = favoriteDao.getAllFavorites() + + suspend fun insertOrUpdateFavorite(favorite: Favorite) { + if (favorite.id != 0) { + favoriteDao.updateFavorites(favorite) + return + } + favoriteDao.insertFavorites(favorite) + } + + suspend fun deleteFavorite(query: String?, type: FavoriteType?) { + if (query == null || type == null) return + val favorite = getFavorite(query, type) ?: return + favoriteDao.deleteFavorites(favorite) + } + + companion object { + @Volatile + private var INSTANCE: FavoriteDataSource? = null + + fun getInstance(context: Context): FavoriteDataSource { + return INSTANCE ?: synchronized(this) { + val dao: FavoriteDao = AppDatabase.getDatabase(context).favoriteDao() + FavoriteDataSource(dao).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.kt b/app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.kt new file mode 100644 index 0000000..67149ea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.kt @@ -0,0 +1,43 @@ +package awais.instagrabber.db.datasources + +import android.content.Context +import awais.instagrabber.db.AppDatabase +import awais.instagrabber.db.dao.RecentSearchDao +import awais.instagrabber.db.entities.RecentSearch +import awais.instagrabber.models.enums.FavoriteType + +class RecentSearchDataSource private constructor(private val recentSearchDao: RecentSearchDao) { + + suspend fun getRecentSearchByIgIdAndType(igId: String, type: FavoriteType): RecentSearch? = + recentSearchDao.getRecentSearchByIgIdAndType(igId, type) + + suspend fun getAllRecentSearches(): List = recentSearchDao.getAllRecentSearches() + + suspend fun insertOrUpdateRecentSearch(recentSearch: RecentSearch) { + if (recentSearch.id != 0) { + recentSearchDao.updateRecentSearch(recentSearch) + return + } + recentSearchDao.insertRecentSearch(recentSearch) + } + + suspend fun deleteRecentSearch(recentSearch: RecentSearch) = recentSearchDao.deleteRecentSearch(recentSearch) + + companion object { + private lateinit var INSTANCE: RecentSearchDataSource + + @JvmStatic + @Synchronized + fun getInstance(context: Context): RecentSearchDataSource { + if (!this::INSTANCE.isInitialized) { + synchronized(RecentSearchDataSource::class.java) { + if (!this::INSTANCE.isInitialized) { + val database = AppDatabase.getDatabase(context) + INSTANCE = RecentSearchDataSource(database.recentSearchDao()) + } + } + } + return INSTANCE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/entities/Account.kt b/app/src/main/java/awais/instagrabber/db/entities/Account.kt new file mode 100644 index 0000000..002f7a1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/entities/Account.kt @@ -0,0 +1,32 @@ +package awais.instagrabber.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey + +@Entity(tableName = Account.TABLE_NAME) +data class Account( + @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, + @ColumnInfo(name = COL_UID) val uid: String?, + @ColumnInfo(name = COL_USERNAME) val username: String?, + @ColumnInfo(name = COL_COOKIE) val cookie: String?, + @ColumnInfo(name = COL_FULL_NAME) val fullName: String?, + @ColumnInfo(name = COL_PROFILE_PIC) val profilePic: String?, +) { + @Ignore + var isSelected = false + + val isValid: Boolean + get() = !uid.isNullOrBlank() && !username.isNullOrBlank() && !cookie.isNullOrBlank() + + companion object { + const val TABLE_NAME = "accounts" + const val COL_ID = "id" + const val COL_USERNAME = "username" + const val COL_COOKIE = "cookie" + const val COL_UID = "uid" + const val COL_FULL_NAME = "full_name" + const val COL_PROFILE_PIC = "profile_pic" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/entities/DMLastNotified.kt b/app/src/main/java/awais/instagrabber/db/entities/DMLastNotified.kt new file mode 100644 index 0000000..51b3117 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/entities/DMLastNotified.kt @@ -0,0 +1,23 @@ +package awais.instagrabber.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +@Entity(tableName = DMLastNotified.TABLE_NAME, indices = [Index(value = [DMLastNotified.COL_THREAD_ID], unique = true)]) +data class DMLastNotified( + @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, + @ColumnInfo(name = COL_THREAD_ID) val threadId: String?, + @ColumnInfo(name = COL_LAST_NOTIFIED_MSG_TS) val lastNotifiedMsgTs: LocalDateTime?, + @ColumnInfo(name = COL_LAST_NOTIFIED_AT) val lastNotifiedAt: LocalDateTime?, +) { + companion object { + const val TABLE_NAME = "dm_last_notified" + const val COL_ID = "id" + const val COL_THREAD_ID = "thread_id" + const val COL_LAST_NOTIFIED_MSG_TS = "last_notified_msg_ts" + const val COL_LAST_NOTIFIED_AT = "last_notified_at" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/entities/Favorite.kt b/app/src/main/java/awais/instagrabber/db/entities/Favorite.kt new file mode 100644 index 0000000..62e5825 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/entities/Favorite.kt @@ -0,0 +1,27 @@ +package awais.instagrabber.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import awais.instagrabber.models.enums.FavoriteType +import java.time.LocalDateTime + +@Entity(tableName = Favorite.TABLE_NAME) +data class Favorite( + @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, + @ColumnInfo(name = COL_QUERY) val query: String?, + @ColumnInfo(name = COL_TYPE) val type: FavoriteType?, + @ColumnInfo(name = COL_DISPLAY_NAME) val displayName: String?, + @ColumnInfo(name = COL_PIC_URL) val picUrl: String?, + @ColumnInfo(name = COL_DATE_ADDED) val dateAdded: LocalDateTime?, +) { + companion object { + const val TABLE_NAME = "favorites" + const val COL_ID = "id" + const val COL_QUERY = "query_text" + const val COL_TYPE = "type" + const val COL_DISPLAY_NAME = "display_name" + const val COL_PIC_URL = "pic_url" + const val COL_DATE_ADDED = "date_added" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/entities/RecentSearch.kt b/app/src/main/java/awais/instagrabber/db/entities/RecentSearch.kt new file mode 100644 index 0000000..d6ff16f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/entities/RecentSearch.kt @@ -0,0 +1,70 @@ +package awais.instagrabber.db.entities + +import android.util.Log +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import awais.instagrabber.models.enums.FavoriteType +import awais.instagrabber.repositories.responses.search.SearchItem +import awais.instagrabber.utils.extensions.TAG +import java.time.LocalDateTime + +@Entity(tableName = RecentSearch.TABLE_NAME, indices = [Index(value = [RecentSearch.COL_IG_ID, RecentSearch.COL_TYPE], unique = true)]) +data class RecentSearch( + @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, + @ColumnInfo(name = COL_IG_ID) val igId: String, + @ColumnInfo(name = COL_NAME) val name: String, + @ColumnInfo(name = COL_USERNAME) val username: String?, + @ColumnInfo(name = COL_PIC_URL) val picUrl: String?, + @ColumnInfo(name = COL_TYPE) val type: FavoriteType, + @ColumnInfo(name = COL_LAST_SEARCHED_ON) val lastSearchedOn: LocalDateTime, +) { + + companion object { + const val TABLE_NAME = "recent_searches" + private const val COL_ID = "id" + const val COL_IG_ID = "ig_id" + private const val COL_NAME = "name" + private const val COL_USERNAME = "username" + private const val COL_PIC_URL = "pic_url" + const val COL_TYPE = "type" + private const val COL_LAST_SEARCHED_ON = "last_searched_on" + + @JvmStatic + fun fromSearchItem(searchItem: SearchItem): RecentSearch? { + val type = searchItem.type ?: return null + try { + val igId: String + val name: String + val username: String? + val picUrl: String? + when (type) { + FavoriteType.USER -> { + igId = searchItem.user.pk.toString() + name = searchItem.user.fullName ?: "" + username = searchItem.user.username + picUrl = searchItem.user.profilePicUrl + } + FavoriteType.HASHTAG -> { + igId = searchItem.hashtag.id + name = searchItem.hashtag.name + username = null + picUrl = null + } + FavoriteType.LOCATION -> { + igId = searchItem.place.location.pk.toString() + name = searchItem.place.title + username = null + picUrl = null + } + else -> return null + } + return RecentSearch(id = 0, igId, name, username, picUrl, type, LocalDateTime.now()) + } catch (e: Exception) { + Log.e(TAG, "fromSearchItem: ", e) + } + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/repositories/AccountRepository.kt b/app/src/main/java/awais/instagrabber/db/repositories/AccountRepository.kt new file mode 100644 index 0000000..174cf17 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/repositories/AccountRepository.kt @@ -0,0 +1,50 @@ +package awais.instagrabber.db.repositories + +import android.content.Context +import awais.instagrabber.db.datasources.AccountDataSource +import awais.instagrabber.db.entities.Account + +class AccountRepository(private val accountDataSource: AccountDataSource) { + suspend fun getAccount(uid: Long): Account? = accountDataSource.getAccount(uid.toString()) + + suspend fun getAllAccounts(): List = accountDataSource.getAllAccounts() + + suspend fun insertOrUpdateAccounts(accounts: List) { + for (account in accounts) { + accountDataSource.insertOrUpdateAccount( + account.uid, + account.username, + account.cookie, + account.fullName, + account.profilePic + ) + } + } + + suspend fun insertOrUpdateAccount( + uid: Long, + username: String, + cookie: String, + fullName: String, + profilePicUrl: String?, + ): Account? { + accountDataSource.insertOrUpdateAccount(uid.toString(), username, cookie, fullName, profilePicUrl) + return accountDataSource.getAccount(uid.toString()) + } + + suspend fun deleteAccount(account: Account) = accountDataSource.deleteAccount(account) + + suspend fun deleteAllAccounts() = accountDataSource.deleteAllAccounts() + + companion object { + @Volatile + private var INSTANCE: AccountRepository? = null + + fun getInstance(context: Context): AccountRepository { + return INSTANCE ?: synchronized(this) { + val dataSource: AccountDataSource = AccountDataSource.getInstance(context) + AccountRepository(dataSource).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/repositories/DMLastNotifiedRepository.kt b/app/src/main/java/awais/instagrabber/db/repositories/DMLastNotifiedRepository.kt new file mode 100644 index 0000000..d419ebf --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/repositories/DMLastNotifiedRepository.kt @@ -0,0 +1,47 @@ +package awais.instagrabber.db.repositories + +import awais.instagrabber.db.datasources.DMLastNotifiedDataSource +import awais.instagrabber.db.entities.DMLastNotified +import java.time.LocalDateTime + +class DMLastNotifiedRepository private constructor(private val dmLastNotifiedDataSource: DMLastNotifiedDataSource) { + + suspend fun getDMLastNotified(threadId: String): DMLastNotified? = dmLastNotifiedDataSource.getDMLastNotified(threadId) + + suspend fun getAllDMDmLastNotified(): List = dmLastNotifiedDataSource.getAllDMDmLastNotified() + + suspend fun insertOrUpdateDMLastNotified(dmLastNotifiedList: List) { + for (dmLastNotified in dmLastNotifiedList) { + dmLastNotifiedDataSource.insertOrUpdateDMLastNotified( + dmLastNotified.threadId, + dmLastNotified.lastNotifiedMsgTs, + dmLastNotified.lastNotifiedAt + ) + } + } + + suspend fun insertOrUpdateDMLastNotified( + threadId: String, + lastNotifiedMsgTs: LocalDateTime, + lastNotifiedAt: LocalDateTime, + ): DMLastNotified? { + dmLastNotifiedDataSource.insertOrUpdateDMLastNotified(threadId, lastNotifiedMsgTs, lastNotifiedAt) + return dmLastNotifiedDataSource.getDMLastNotified(threadId) + } + + suspend fun deleteDMLastNotified(dmLastNotified: DMLastNotified) = dmLastNotifiedDataSource.deleteDMLastNotified(dmLastNotified) + + suspend fun deleteAllDMLastNotified() = dmLastNotifiedDataSource.deleteAllDMLastNotified() + + companion object { + private lateinit var instance: DMLastNotifiedRepository + + @JvmStatic + fun getInstance(dmLastNotifiedDataSource: DMLastNotifiedDataSource): DMLastNotifiedRepository { + if (!this::instance.isInitialized) { + instance = DMLastNotifiedRepository(dmLastNotifiedDataSource) + } + return instance + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/repositories/FavoriteRepository.kt b/app/src/main/java/awais/instagrabber/db/repositories/FavoriteRepository.kt new file mode 100644 index 0000000..acec8e5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/repositories/FavoriteRepository.kt @@ -0,0 +1,29 @@ +package awais.instagrabber.db.repositories + +import android.content.Context +import awais.instagrabber.db.datasources.FavoriteDataSource +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.models.enums.FavoriteType + +class FavoriteRepository(private val favoriteDataSource: FavoriteDataSource) { + + suspend fun getFavorite(query: String, type: FavoriteType): Favorite? = favoriteDataSource.getFavorite(query, type) + + suspend fun getAllFavorites(): List = favoriteDataSource.getAllFavorites() + + suspend fun insertOrUpdateFavorite(favorite: Favorite) = favoriteDataSource.insertOrUpdateFavorite(favorite) + + suspend fun deleteFavorite(query: String?, type: FavoriteType?) = favoriteDataSource.deleteFavorite(query, type) + + companion object { + @Volatile + private var INSTANCE: FavoriteRepository? = null + + fun getInstance(context: Context): FavoriteRepository { + return INSTANCE ?: synchronized(this) { + val dataSource: FavoriteDataSource = FavoriteDataSource.getInstance(context) + FavoriteRepository(dataSource).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.kt b/app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.kt new file mode 100644 index 0000000..69ca42d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.kt @@ -0,0 +1,48 @@ +package awais.instagrabber.db.repositories + +import awais.instagrabber.db.datasources.RecentSearchDataSource +import awais.instagrabber.db.entities.RecentSearch +import awais.instagrabber.models.enums.FavoriteType +import java.time.LocalDateTime + +class RecentSearchRepository private constructor(private val recentSearchDataSource: RecentSearchDataSource) { + suspend fun getRecentSearch(igId: String, type: FavoriteType): RecentSearch? = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type) + + suspend fun getAllRecentSearches(): List = recentSearchDataSource.getAllRecentSearches() + + suspend fun insertOrUpdateRecentSearch(recentSearch: RecentSearch) = + insertOrUpdateRecentSearch(recentSearch.igId, recentSearch.name, recentSearch.username, recentSearch.picUrl, recentSearch.type) + + private suspend fun insertOrUpdateRecentSearch( + igId: String, + name: String, + username: String?, + picUrl: String?, + type: FavoriteType, + ) { + var recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type) + recentSearch = RecentSearch(recentSearch?.id ?: 0, igId, name, username, picUrl, type, LocalDateTime.now()) + recentSearchDataSource.insertOrUpdateRecentSearch(recentSearch) + } + + suspend fun deleteRecentSearchByIgIdAndType(igId: String, type: FavoriteType) { + val recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type) + if (recentSearch != null) { + recentSearchDataSource.deleteRecentSearch(recentSearch) + } + } + + suspend fun deleteRecentSearch(recentSearch: RecentSearch) = recentSearchDataSource.deleteRecentSearch(recentSearch) + + companion object { + private lateinit var instance: RecentSearchRepository + + @JvmStatic + fun getInstance(recentSearchDataSource: RecentSearchDataSource): RecentSearchRepository { + if (!this::instance.isInitialized) { + instance = RecentSearchRepository(recentSearchDataSource) + } + return instance + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/dialogs/AccountSwitcherDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/AccountSwitcherDialogFragment.java new file mode 100644 index 0000000..cf874b7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/AccountSwitcherDialogFragment.java @@ -0,0 +1,191 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.AccountSwitcherAdapter; +import awais.instagrabber.databinding.DialogAccountSwitcherBinding; +import awais.instagrabber.db.entities.Account; +import awais.instagrabber.db.repositories.AccountRepository; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.ProcessPhoenix; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class AccountSwitcherDialogFragment extends DialogFragment { + private static final String TAG = AccountSwitcherDialogFragment.class.getSimpleName(); + + private AccountRepository accountRepository; + + private OnAddAccountClickListener onAddAccountClickListener; + private DialogAccountSwitcherBinding binding; + + public AccountSwitcherDialogFragment() {} + + public AccountSwitcherDialogFragment(final OnAddAccountClickListener onAddAccountClickListener) { + this.onAddAccountClickListener = onAddAccountClickListener; + } + + private final AccountSwitcherAdapter.OnAccountClickListener accountClickListener = (model, isCurrent) -> { + if (isCurrent) { + dismiss(); + return; + } + CookieUtils.setupCookies(model.getCookie()); + settingsHelper.putString(Constants.COOKIE, model.getCookie()); + // final FragmentActivity activity = getActivity(); + // if (activity != null) activity.recreate(); + // dismiss(); + AppExecutors.INSTANCE.getMainThread().execute(() -> { + final Context context = getContext(); + if (context == null) return; + ProcessPhoenix.triggerRebirth(context); + }, 200); + }; + + private final AccountSwitcherAdapter.OnAccountLongClickListener accountLongClickListener = (model, isCurrent) -> { + final Context context = getContext(); + if (context == null) return false; + if (isCurrent) { + new AlertDialog.Builder(context) + .setMessage(R.string.quick_access_cannot_delete_curr) + .setPositiveButton(R.string.ok, null) + .show(); + return true; + } + new AlertDialog.Builder(context) + .setMessage(getString(R.string.quick_access_confirm_delete, model.getUsername())) + .setPositiveButton(R.string.yes, (dialog, which) -> { + if (accountRepository == null) return; + accountRepository.deleteAccount( + model, + CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + dismiss(); + if (throwable != null) { + Log.e(TAG, "deleteAccount: ", throwable); + } + }), Dispatchers.getIO()) + ); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + dismiss(); + return true; + }; + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + binding = DialogAccountSwitcherBinding.inflate(inflater, container, false); + binding.accounts.setLayoutManager(new LinearLayoutManager(getContext())); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + init(); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + accountRepository = AccountRepository.Companion.getInstance(context); + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + final int height = ViewGroup.LayoutParams.WRAP_CONTENT; + final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); + window.setLayout(width, height); + } + + private void init() { + final AccountSwitcherAdapter adapter = new AccountSwitcherAdapter(accountClickListener, accountLongClickListener); + binding.accounts.setAdapter(adapter); + if (accountRepository == null) return; + accountRepository.getAllAccounts( + CoroutineUtilsKt.getContinuation((accounts, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "init: ", throwable); + return; + } + if (accounts == null) return; + final String cookie = settingsHelper.getString(Constants.COOKIE); + final List copy = new ArrayList<>(accounts); + sortUserList(cookie, copy); + adapter.submitList(copy); + }), Dispatchers.getIO()) + ); + binding.addAccountBtn.setOnClickListener(v -> { + if (onAddAccountClickListener == null) return; + onAddAccountClickListener.onAddAccountClick(this); + }); + } + + /** + * Sort the user list by following logic: + *

    + *
  1. Keep currently active account at top. + *
  2. Check if any user does not have a full name. + *
  3. If all have full names, sort by full names. + *
  4. Otherwise, sort by the usernames + *
+ * + * @param cookie active cookie + * @param allUsers list of users + */ + private void sortUserList(final String cookie, final List allUsers) { + boolean sortByName = true; + for (final Account user : allUsers) { + if (TextUtils.isEmpty(user.getFullName())) { + sortByName = false; + break; + } + } + final boolean finalSortByName = sortByName; + Collections.sort(allUsers, (o1, o2) -> { + // keep current account at top + if (o1.getCookie().equals(cookie)) return -1; + if (finalSortByName) { + // sort by full name + return o1.getFullName().compareTo(o2.getFullName()); + } + // otherwise sort by username + return o1.getUsername().compareTo(o2.getUsername()); + }); + } + + public interface OnAddAccountClickListener { + void onAddAccountClick(final AccountSwitcherDialogFragment dialogFragment); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/ConfirmDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/ConfirmDialogFragment.java new file mode 100644 index 0000000..70d4c9f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/ConfirmDialogFragment.java @@ -0,0 +1,165 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import awais.instagrabber.R; + +public class ConfirmDialogFragment extends DialogFragment { + private Context context; + private ConfirmDialogFragmentCallback callback; + + private final int defaultPositiveButtonText = R.string.ok; + // private final int defaultNegativeButtonText = R.string.cancel; + + @NonNull + public static ConfirmDialogFragment newInstance(final int requestCode, + @StringRes final int title, + @NonNull final CharSequence message, + @StringRes final int positiveText, + @StringRes final int negativeText, + @StringRes final int neutralText) { + return newInstance(requestCode, title, 0, message, positiveText, negativeText, neutralText); + } + + @NonNull + public static ConfirmDialogFragment newInstance(final int requestCode, + @StringRes final int title, + @StringRes final int messageResId, + @StringRes final int positiveText, + @StringRes final int negativeText, + @StringRes final int neutralText) { + return newInstance(requestCode, title, messageResId, null, positiveText, negativeText, neutralText); + } + + @NonNull + private static ConfirmDialogFragment newInstance(final int requestCode, + @StringRes final int title, + @StringRes final int messageResId, + @Nullable final CharSequence message, + @StringRes final int positiveText, + @StringRes final int negativeText, + @StringRes final int neutralText) { + Bundle args = new Bundle(); + args.putInt("requestCode", requestCode); + if (title != 0) { + args.putInt("title", title); + } + if (messageResId != 0) { + args.putInt("messageResId", messageResId); + } else if (message != null) { + args.putCharSequence("message", message); + } + if (positiveText != 0) { + args.putInt("positive", positiveText); + } + if (negativeText != 0) { + args.putInt("negative", negativeText); + } + if (neutralText != 0) { + args.putInt("neutral", neutralText); + } + ConfirmDialogFragment fragment = new ConfirmDialogFragment(); + fragment.setArguments(args); + return fragment; + + } + + public ConfirmDialogFragment() {} + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + this.context = context; + final Fragment parentFragment = getParentFragment(); + if (parentFragment instanceof ConfirmDialogFragmentCallback) { + callback = (ConfirmDialogFragmentCallback) parentFragment; + return; + } + final FragmentActivity fragmentActivity = getActivity(); + if (fragmentActivity instanceof ConfirmDialogFragmentCallback) { + callback = (ConfirmDialogFragmentCallback) fragmentActivity; + } + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + final Bundle arguments = getArguments(); + int title = 0; + int messageResId = 0; + CharSequence message = null; + int neutralButtonText = 0; + int negativeButtonText = 0; + + final int positiveButtonText; + final int requestCode; + if (arguments != null) { + title = arguments.getInt("title", 0); + messageResId = arguments.getInt("messageResId", 0); + message = arguments.getCharSequence("message", null); + positiveButtonText = arguments.getInt("positive", defaultPositiveButtonText); + negativeButtonText = arguments.getInt("negative", 0); + neutralButtonText = arguments.getInt("neutral", 0); + requestCode = arguments.getInt("requestCode", 0); + } else { + requestCode = 0; + positiveButtonText = defaultPositiveButtonText; + } + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context) + .setPositiveButton(positiveButtonText, (d, w) -> { + if (callback == null) return; + callback.onPositiveButtonClicked(requestCode); + }); + if (title != 0) { + builder.setTitle(title); + } + if (messageResId != 0) { + builder.setMessage(messageResId); + } else if (message != null) { + builder.setMessage(message); + } + if (negativeButtonText != 0) { + builder.setNegativeButton(negativeButtonText, (dialog, which) -> { + if (callback == null) return; + callback.onNegativeButtonClicked(requestCode); + }); + } + if (neutralButtonText != 0) { + builder.setNeutralButton(neutralButtonText, (dialog, which) -> { + if (callback == null) return; + callback.onNeutralButtonClicked(requestCode); + }); + } + return builder.create(); + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final TextView view = dialog.findViewById(android.R.id.message); + view.setMovementMethod(LinkMovementMethod.getInstance()); + } + + public interface ConfirmDialogFragmentCallback { + void onPositiveButtonClicked(int requestCode); + + void onNegativeButtonClicked(int requestCode); + + void onNeutralButtonClicked(int requestCode); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/CreateBackupDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/CreateBackupDialogFragment.java new file mode 100644 index 0000000..e893ae3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/CreateBackupDialogFragment.java @@ -0,0 +1,171 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.provider.DocumentsContract; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.inputmethod.InputMethodManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; +import java.util.Locale; + +import awais.instagrabber.databinding.DialogCreateBackupBinding; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.ExportImportUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static android.app.Activity.RESULT_OK; + +public class CreateBackupDialogFragment extends DialogFragment { + private static final String TAG = CreateBackupDialogFragment.class.getSimpleName(); + private static final int STORAGE_PERM_REQUEST_CODE = 8020; + private static final DateTimeFormatter BACKUP_FILE_DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss", Locale.US); + private static final int CREATE_FILE_REQUEST_CODE = 1; + + + private final OnResultListener onResultListener; + private DialogCreateBackupBinding binding; + + public CreateBackupDialogFragment(final OnResultListener onResultListener) { + this.onResultListener = onResultListener; + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + binding = DialogCreateBackupBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + return dialog; + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + final int height = ViewGroup.LayoutParams.WRAP_CONTENT; + final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); + window.setLayout(width, height); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + init(); + } + + private void init() { + binding.etPassword.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {} + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + binding.btnSaveTo.setEnabled(!TextUtils.isEmpty(s)); + } + + @Override + public void afterTextChanged(final Editable s) {} + }); + final Context context = getContext(); + if (context == null) { + return; + } + binding.cbPassword.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + if (TextUtils.isEmpty(binding.etPassword.getText())) { + binding.btnSaveTo.setEnabled(false); + } + binding.passwordField.setVisibility(View.VISIBLE); + binding.etPassword.requestFocus(); + final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.showSoftInput(binding.etPassword, InputMethodManager.SHOW_IMPLICIT); + return; + } + binding.btnSaveTo.setEnabled(true); + binding.passwordField.setVisibility(View.GONE); + final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.hideSoftInputFromWindow(binding.etPassword.getWindowToken(), InputMethodManager.RESULT_UNCHANGED_SHOWN); + }); + binding.btnSaveTo.setOnClickListener(v -> { + createFile(); + }); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + if (data == null || data.getData() == null) return; + if (resultCode != RESULT_OK || requestCode != CREATE_FILE_REQUEST_CODE) return; + final Context context = getContext(); + if (context == null) return; + final Editable passwordText = binding.etPassword.getText(); + final String password = binding.cbPassword.isChecked() + && passwordText != null + && !TextUtils.isEmpty(passwordText.toString()) + ? passwordText.toString().trim() + : null; + int flags = 0; + if (binding.cbExportFavorites.isChecked()) { + flags |= ExportImportUtils.FLAG_FAVORITES; + } + if (binding.cbExportSettings.isChecked()) { + flags |= ExportImportUtils.FLAG_SETTINGS; + } + if (binding.cbExportLogins.isChecked()) { + flags |= ExportImportUtils.FLAG_COOKIES; + } + ExportImportUtils.exportData(context, flags, data.getData(), password, result -> { + if (onResultListener != null) { + onResultListener.onResult(result); + } + dismiss(); + }); + } + + private void createFile() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/octet-stream"); + final String fileName = String.format("barinsta_%s.backup", LocalDateTime.now().format(BACKUP_FILE_DATE_TIME_FORMAT)); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + + // Optionally, specify a URI for the directory that should be opened in + // the system file picker when your app creates the document. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, DownloadUtils.getBackupsDir().getUri()); + } + + startActivityForResult(intent, CREATE_FILE_REQUEST_CODE); + } + + + public interface OnResultListener { + void onResult(boolean result); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/DirectItemReactionDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/DirectItemReactionDialogFragment.java new file mode 100644 index 0000000..f816723 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/DirectItemReactionDialogFragment.java @@ -0,0 +1,121 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectReactionsAdapter; +import awais.instagrabber.adapters.DirectReactionsAdapter.OnReactionClickListener; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItemReactions; +import awais.instagrabber.utils.TextUtils; + +public class DirectItemReactionDialogFragment extends BottomSheetDialogFragment { + + private static final String ARG_VIEWER_ID = "viewerId"; + private static final String ARG_ITEM_ID = "itemId"; + private static final String ARG_USERS = "users"; + private static final String ARG_REACTIONS = "reactions"; + + private RecyclerView recyclerView; + private OnReactionClickListener onReactionClickListener; + + public static DirectItemReactionDialogFragment newInstance(final long viewerId, + @NonNull final ArrayList users, + @NonNull final String itemId, + @NonNull final DirectItemReactions reactions) { + Bundle args = new Bundle(); + args.putLong(ARG_VIEWER_ID, viewerId); + args.putSerializable(ARG_USERS, users); + args.putString(ARG_ITEM_ID, itemId); + args.putSerializable(ARG_REACTIONS, reactions); + DirectItemReactionDialogFragment fragment = new DirectItemReactionDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + public DirectItemReactionDialogFragment() {} + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NORMAL, R.style.ThemeOverlay_Rounded_BottomSheetDialog); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + final Context context = getContext(); + if (context == null) { + return null; + } + recyclerView = new RecyclerView(context); + return recyclerView; + + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + try { + onReactionClickListener = (OnReactionClickListener) getParentFragment(); + } catch (ClassCastException e) { + throw new ClassCastException("Calling fragment must implement DirectReactionsAdapter.OnReactionClickListener interface"); + } + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; + final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + if (bottomSheetInternal == null) return; + bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + bottomSheetInternal.requestLayout(); + } + + private void init() { + final Context context = getContext(); + if (context == null) return; + final Bundle arguments = getArguments(); + if (arguments == null) return; + final long viewerId = arguments.getLong(ARG_VIEWER_ID); + final Serializable usersSerializable = arguments.getSerializable(ARG_USERS); + if (!(usersSerializable instanceof ArrayList)) return; + //noinspection unchecked + final List users = (ArrayList) usersSerializable; + final Serializable reactionsSerializable = arguments.getSerializable(ARG_REACTIONS); + if (!(reactionsSerializable instanceof DirectItemReactions)) return; + final DirectItemReactions reactions = (DirectItemReactions) reactionsSerializable; + final String itemId = arguments.getString(ARG_ITEM_ID); + if (TextUtils.isEmpty(itemId)) return; + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + final DirectReactionsAdapter adapter = new DirectReactionsAdapter(viewerId, users, itemId, onReactionClickListener); + recyclerView.setAdapter(adapter); + adapter.submitList(reactions.getEmojis()); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/EditTextDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/EditTextDialogFragment.java new file mode 100644 index 0000000..7a68d45 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/EditTextDialogFragment.java @@ -0,0 +1,109 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import awais.instagrabber.R; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +public class EditTextDialogFragment extends DialogFragment { + + private final int margin; + private final int topMargin; + + private Context context; + private EditTextDialogFragmentCallback callback; + + public static EditTextDialogFragment newInstance(@StringRes final int title, + @StringRes final int positiveText, + @StringRes final int negativeText, + @Nullable final String initialText) { + Bundle args = new Bundle(); + args.putInt("title", title); + args.putInt("positive", positiveText); + args.putInt("negative", negativeText); + args.putString("initial", initialText); + EditTextDialogFragment fragment = new EditTextDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + public EditTextDialogFragment() { + margin = Utils.convertDpToPx(20); + topMargin = Utils.convertDpToPx(8); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + try { + callback = (EditTextDialogFragmentCallback) getParentFragment(); + } catch (ClassCastException e) { + throw new ClassCastException("Calling fragment must implement EditTextDialogFragmentCallback interface"); + } + this.context = context; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + final Bundle arguments = getArguments(); + int title = -1; + int positiveButtonText = R.string.ok; + int negativeButtonText = R.string.cancel; + String initialText = null; + if (arguments != null) { + title = arguments.getInt("title", -1); + positiveButtonText = arguments.getInt("positive", R.string.ok); + negativeButtonText = arguments.getInt("negative", R.string.cancel); + initialText = arguments.getString("initial", null); + } + final AppCompatEditText input = new AppCompatEditText(context); + if (!TextUtils.isEmpty(initialText)) { + input.setText(initialText); + } + final FrameLayout container = new FrameLayout(context); + final FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + layoutParams.leftMargin = margin; + layoutParams.rightMargin = margin; + layoutParams.topMargin = topMargin; + input.setLayoutParams(layoutParams); + container.addView(input); + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context) + .setView(container) + .setPositiveButton(positiveButtonText, (d, w) -> { + final String string = input.getText() != null ? input.getText().toString() : ""; + if (callback != null) { + callback.onPositiveButtonClicked(string); + } + }) + .setNegativeButton(negativeButtonText, (dialog, which) -> { + if (callback != null) { + callback.onNegativeButtonClicked(); + } + }); + if (title > 0) { + builder.setTitle(title); + } + return builder.create(); + } + + public interface EditTextDialogFragmentCallback { + void onPositiveButtonClicked(String text); + + void onNegativeButtonClicked(); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/GifPickerBottomDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/GifPickerBottomDialogFragment.java new file mode 100644 index 0000000..3d32208 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/GifPickerBottomDialogFragment.java @@ -0,0 +1,153 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +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.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.GridLayoutManager; + +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.snackbar.Snackbar; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.GifItemsAdapter; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.databinding.LayoutGifPickerBinding; +import awais.instagrabber.repositories.responses.giphy.GiphyGif; +import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.viewmodels.GifPickerViewModel; + +public class GifPickerBottomDialogFragment extends BottomSheetDialogFragment { + private static final String TAG = GifPickerBottomDialogFragment.class.getSimpleName(); + private static final int INPUT_DEBOUNCE_INTERVAL = 500; + private static final String INPUT_KEY = "gif_search_input"; + + private LayoutGifPickerBinding binding; + private GifPickerViewModel viewModel; + private GifItemsAdapter gifItemsAdapter; + private OnSelectListener onSelectListener; + private Debouncer inputDebouncer; + + public static GifPickerBottomDialogFragment newInstance() { + final Bundle args = new Bundle(); + final GifPickerBottomDialogFragment fragment = new GifPickerBottomDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NORMAL, R.style.ThemeOverlay_Rounded_BottomSheetDialog); + final Debouncer.Callback callback = new Debouncer.Callback() { + @Override + public void call(final String key) { + final Editable text = binding.input.getText(); + if (TextUtils.isEmpty(text)) { + viewModel.search(null); + return; + } + viewModel.search(text.toString().trim()); + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "onError: ", t); + } + }; + inputDebouncer = new Debouncer<>(callback, INPUT_DEBOUNCE_INTERVAL); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = LayoutGifPickerBinding.inflate(inflater, container, false); + viewModel = new ViewModelProvider(this).get(GifPickerViewModel.class); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(); + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; + final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + if (bottomSheetInternal == null) return; + bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + bottomSheetInternal.requestLayout(); + } + + private void init() { + setupList(); + setupInput(); + setupObservers(); + } + + private void setupList() { + final Context context = getContext(); + if (context == null) return; + binding.gifList.setLayoutManager(new GridLayoutManager(context, 3)); + binding.gifList.setHasFixedSize(true); + gifItemsAdapter = new GifItemsAdapter(entry -> { + if (onSelectListener == null) return; + onSelectListener.onSelect(entry); + }); + binding.gifList.setAdapter(gifItemsAdapter); + } + + private void setupInput() { + binding.input.addTextChangedListener(new TextWatcherAdapter() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + inputDebouncer.call(INPUT_KEY); + } + }); + } + + private void setupObservers() { + viewModel.getImages().observe(getViewLifecycleOwner(), imagesResource -> { + if (imagesResource == null) return; + switch (imagesResource.status) { + case SUCCESS: + gifItemsAdapter.submitList(imagesResource.data); + break; + case ERROR: + final Context context = getContext(); + if (context != null && imagesResource.message != null) { + Snackbar.make(context, binding.getRoot(), imagesResource.message, Snackbar.LENGTH_LONG).show(); + } + if (context != null && imagesResource.resId != 0) { + Snackbar.make(context, binding.getRoot(), getString(imagesResource.resId), Snackbar.LENGTH_LONG).show(); + } + break; + case LOADING: + break; + } + }); + } + + public void setOnSelectListener(final OnSelectListener onSelectListener) { + this.onSelectListener = onSelectListener; + } + + public interface OnSelectListener { + void onSelect(GiphyGif giphyGif); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/KeywordsFilterDialog.java b/app/src/main/java/awais/instagrabber/dialogs/KeywordsFilterDialog.java new file mode 100644 index 0000000..5997371 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/KeywordsFilterDialog.java @@ -0,0 +1,78 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.HashSet; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.KeywordsFilterAdapter; +import awais.instagrabber.databinding.DialogKeywordsFilterBinding; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.utils.SettingsHelper; +import awais.instagrabber.utils.Utils; + +public final class KeywordsFilterDialog extends DialogFragment { + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + final int height = ViewGroup.LayoutParams.WRAP_CONTENT; + final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); + window.setLayout(width, height); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + final DialogKeywordsFilterBinding dialogKeywordsFilterBinding = DialogKeywordsFilterBinding.inflate(inflater, container, false); + init(dialogKeywordsFilterBinding, getContext()); + dialogKeywordsFilterBinding.btnOK.setOnClickListener(view -> this.dismiss()); + return dialogKeywordsFilterBinding.getRoot(); + } + + private void init(DialogKeywordsFilterBinding dialogKeywordsFilterBinding, Context context){ + final LinearLayoutManager linearLayoutManager = new LinearLayoutManager(context); + final RecyclerView recyclerView = dialogKeywordsFilterBinding.recyclerKeyword; + recyclerView.setLayoutManager(linearLayoutManager); + + final SettingsHelper settingsHelper = new SettingsHelper(context); + final ArrayList items = new ArrayList<>(settingsHelper.getStringSet(PreferenceKeys.KEYWORD_FILTERS)); + final KeywordsFilterAdapter adapter = new KeywordsFilterAdapter(context, items); + recyclerView.setAdapter(adapter); + + final EditText editText = dialogKeywordsFilterBinding.editText; + + dialogKeywordsFilterBinding.btnAdd.setOnClickListener(view ->{ + final String s = editText.getText().toString(); + if(s.isEmpty()) return; + if(items.contains(s)) { + editText.setText(""); + return; + } + items.add(s.toLowerCase()); + settingsHelper.putStringSet(PreferenceKeys.KEYWORD_FILTERS, new HashSet<>(items)); + adapter.notifyItemInserted(items.size()); + final String message = context.getString(R.string.added_keywords, s); + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + editText.setText(""); + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java new file mode 100644 index 0000000..a1468b0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java @@ -0,0 +1,263 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.util.SparseBooleanArray; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.common.primitives.Booleans; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class MultiOptionDialogFragment extends DialogFragment { + private static final String TAG = MultiOptionDialogFragment.class.getSimpleName(); + + public enum Type { + MULTIPLE, + SINGLE_CHECKED, + SINGLE + } + + private Context context; + private Type type; + private MultiOptionDialogCallback callback; + private MultiOptionDialogSingleCallback singleCallback; + private List> options; + + @NonNull + public static MultiOptionDialogFragment newInstance(final int requestCode, + @StringRes final int title, + @NonNull final ArrayList> options) { + return newInstance(requestCode, title, 0, 0, options, Type.SINGLE); + } + + @NonNull + public static MultiOptionDialogFragment newInstance(final int requestCode, + @StringRes final int title, + @StringRes final int positiveButtonText, + @StringRes final int negativeButtonText, + @NonNull final ArrayList> options, + @NonNull final Type type) { + Bundle args = new Bundle(); + args.putInt("requestCode", requestCode); + args.putInt("title", title); + args.putInt("positiveButtonText", positiveButtonText); + args.putInt("negativeButtonText", negativeButtonText); + args.putSerializable("options", options); + args.putSerializable("type", type); + MultiOptionDialogFragment fragment = new MultiOptionDialogFragment<>(); + fragment.setArguments(args); + return fragment; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + this.context = context; + final Fragment parentFragment = getParentFragment(); + if (parentFragment != null) { + if (parentFragment instanceof MultiOptionDialogCallback) { + callback = (MultiOptionDialogCallback) parentFragment; + } + if (parentFragment instanceof MultiOptionDialogSingleCallback) { + singleCallback = (MultiOptionDialogSingleCallback) parentFragment; + } + return; + } + final FragmentActivity fragmentActivity = getActivity(); + if (fragmentActivity instanceof MultiOptionDialogCallback) { + callback = (MultiOptionDialogCallback) fragmentActivity; + } + if (fragmentActivity instanceof MultiOptionDialogSingleCallback) { + singleCallback = (MultiOptionDialogSingleCallback) fragmentActivity; + } + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Bundle arguments = getArguments(); + int title = 0; + int rc = 0; + if (arguments != null) { + rc = arguments.getInt("requestCode"); + title = arguments.getInt("title"); + type = (Type) arguments.getSerializable("type"); + } + final int requestCode = rc; + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context); + if (title != 0) { + builder.setTitle(title); + } + try { + //noinspection unchecked + options = arguments != null ? (List>) arguments.getSerializable("options") + : Collections.emptyList(); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + options = Collections.emptyList(); + } + final int negativeButtonText = arguments != null ? arguments.getInt("negativeButtonText", -1) : -1; + if (negativeButtonText > 0) { + builder.setNegativeButton(negativeButtonText, (dialog, which) -> { + if (callback != null) { + callback.onCancel(requestCode); + return; + } + if (singleCallback != null) { + singleCallback.onCancel(requestCode); + } + }); + } + if (type == Type.MULTIPLE || type == Type.SINGLE_CHECKED) { + final int positiveButtonText = arguments != null ? arguments.getInt("positiveButtonText", -1) : -1; + if (positiveButtonText > 0) { + builder.setPositiveButton(positiveButtonText, (dialog, which) -> { + if (callback == null || options == null || options.isEmpty()) return; + try { + final List selected = new ArrayList<>(); + final SparseBooleanArray checkedItemPositions = ((AlertDialog) dialog).getListView().getCheckedItemPositions(); + for (int i = 0; i < checkedItemPositions.size(); i++) { + final int position = checkedItemPositions.keyAt(i); + final boolean checked = checkedItemPositions.get(position); + if (!checked) continue; + //noinspection unchecked + final Option option = (Option) options.get(position); + selected.add(option.value); + } + callback.onMultipleSelect(requestCode, selected); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } + } + if (type == Type.MULTIPLE) { + if (options != null && !options.isEmpty()) { + final String[] items = options.stream() + .map(option -> option.label) + .toArray(String[]::new); + final boolean[] checkedItems = Booleans.toArray(options.stream() + .map(option -> option.checked) + .collect(Collectors.toList())); + builder.setMultiChoiceItems(items, checkedItems, (dialog, which, isChecked) -> { + if (callback == null) return; + try { + final Option option = options.get(which); + //noinspection unchecked + callback.onCheckChange(requestCode, (T) option.value, isChecked); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } + } else { + if (options != null && !options.isEmpty()) { + final String[] items = options.stream() + .map(option -> option.label) + .toArray(String[]::new); + if (type == Type.SINGLE_CHECKED) { + int index = -1; + for (int i = 0; i < options.size(); i++) { + if (options.get(i).checked) { + index = i; + break; + } + } + builder.setSingleChoiceItems(items, index, (dialog, which) -> { + if (callback == null) return; + try { + final Option option = options.get(which); + //noinspection unchecked + callback.onCheckChange(requestCode, (T) option.value, true); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } else if (type == Type.SINGLE) { + builder.setItems(items, (dialog, which) -> { + if (singleCallback == null) return; + try { + final Option option = options.get(which); + //noinspection unchecked + singleCallback.onSelect(requestCode, (T) option.value); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } + } + } + return builder.create(); + } + + public void setCallback(final MultiOptionDialogCallback callback) { + if (callback == null) return; + this.callback = callback; + } + + public void setSingleCallback(final MultiOptionDialogSingleCallback callback) { + if (callback == null) return; + this.singleCallback = callback; + } + + public interface MultiOptionDialogCallback { + void onSelect(int requestCode, T result); + + void onMultipleSelect(int requestCode, List result); + + void onCheckChange(int requestCode, T item, boolean isChecked); + + void onCancel(int requestCode); + } + + public interface MultiOptionDialogSingleCallback { + void onSelect(int requestCode, T result); + + void onCancel(int requestCode); + } + + public static class Option { + private final String label; + private final T value; + private final boolean checked; + + public Option(final String label, final T value) { + this.label = label; + this.value = value; + this.checked = false; + } + + public Option(final String label, final T value, final boolean checked) { + this.label = label; + this.value = value; + this.checked = checked; + } + + public String getLabel() { + return label; + } + + public T getValue() { + return value; + } + + public boolean isChecked() { + return checked; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/PostLoadingDialogFragment.kt b/app/src/main/java/awais/instagrabber/dialogs/PostLoadingDialogFragment.kt new file mode 100644 index 0000000..66cea86 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/PostLoadingDialogFragment.kt @@ -0,0 +1,78 @@ +package awais.instagrabber.dialogs + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import awais.instagrabber.R +import awais.instagrabber.utils.* +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.webservices.GraphQLRepository +import awais.instagrabber.webservices.MediaRepository +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.* + +class PostLoadingDialogFragment : DialogFragment() { + private var isLoggedIn: Boolean = false + + private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } + private val graphQLRepository: GraphQLRepository by lazy { GraphQLRepository.getInstance() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + var userId: Long = 0 + var csrfToken: String? = null + if (cookie.isNotBlank()) { + userId = getUserIdFromCookie(cookie) + csrfToken = getCsrfTokenFromCookie(cookie) + } + if (cookie.isBlank() || userId == 0L || csrfToken.isNullOrBlank()) { + isLoggedIn = false + return + } + isLoggedIn = true + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setCancelable(false) + .setView(R.layout.dialog_opening_post) + .create() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + val arguments = PostLoadingDialogFragmentArgs.fromBundle(arguments ?: return) + val shortCode = arguments.shortCode + lifecycleScope.launch(Dispatchers.IO) { + try { + val media = if (isLoggedIn) mediaRepository.fetch(TextUtils.shortcodeToId(shortCode)) else graphQLRepository.fetchPost(shortCode) + withContext(Dispatchers.Main) { + if (media == null) { + Toast.makeText(context, R.string.post_not_found, Toast.LENGTH_SHORT).show() + return@withContext + } + try { + findNavController().navigate(PostLoadingDialogFragmentDirections.actionToPost(media, 0)) + } catch (e: Exception) { + Log.e(TAG, "showPostView: ", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "showPostView: ", e) + } finally { + withContext(Dispatchers.Main) { + dismiss() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/dialogs/PostsLayoutPreferencesDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/PostsLayoutPreferencesDialogFragment.java new file mode 100644 index 0000000..9e35ae5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/PostsLayoutPreferencesDialogFragment.java @@ -0,0 +1,216 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.DialogPostLayoutPreferencesBinding; +import awais.instagrabber.models.PostsLayoutPreferences; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class PostsLayoutPreferencesDialogFragment extends DialogFragment { + + private final OnApplyListener onApplyListener; + private final PostsLayoutPreferences.Builder preferencesBuilder; + private final String layoutPreferenceKey; + private DialogPostLayoutPreferencesBinding binding; + private Context context; + + public PostsLayoutPreferencesDialogFragment(final String layoutPreferenceKey, + @NonNull final OnApplyListener onApplyListener) { + this.layoutPreferenceKey = layoutPreferenceKey; + final PostsLayoutPreferences preferences = PostsLayoutPreferences.fromJson(settingsHelper.getString(layoutPreferenceKey)); + this.preferencesBuilder = PostsLayoutPreferences.builder().mergeFrom(preferences); + this.onApplyListener = onApplyListener; + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + this.context = context; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + binding = DialogPostLayoutPreferencesBinding.inflate(LayoutInflater.from(context), null, false); + init(); + return new MaterialAlertDialogBuilder(context) + .setView(binding.getRoot()) + .setPositiveButton(R.string.apply, (dialog, which) -> { + final PostsLayoutPreferences preferences = preferencesBuilder.build(); + final String json = preferences.getJson(); + settingsHelper.putString(layoutPreferenceKey, json); + onApplyListener.onApply(preferences); + }) + .create(); + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + window.setWindowAnimations(R.style.dialog_window_animation); + } + + private void init() { + initLayoutToggle(); + if (preferencesBuilder.getType() != PostsLayoutPreferences.PostsLayoutType.LINEAR) { + initStaggeredOrGridOptions(); + } + } + + private void initStaggeredOrGridOptions() { + initColCountToggle(); + initNamesToggle(); + initAvatarsToggle(); + initCornersToggle(); + initGapToggle(); + } + + private void initLayoutToggle() { + final int selectedLayoutId = getSelectedLayoutId(); + binding.layoutToggle.check(selectedLayoutId); + if (selectedLayoutId == R.id.layout_linear) { + binding.staggeredOrGridOptions.setVisibility(View.GONE); + } + binding.layoutToggle.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (isChecked) { + if (checkedId == R.id.layout_linear) { + preferencesBuilder.setType(PostsLayoutPreferences.PostsLayoutType.LINEAR); + preferencesBuilder.setColCount(1); + binding.staggeredOrGridOptions.setVisibility(View.GONE); + } else if (checkedId == R.id.layout_staggered) { + preferencesBuilder.setType(PostsLayoutPreferences.PostsLayoutType.STAGGERED_GRID); + if (preferencesBuilder.getColCount() == 1) { + preferencesBuilder.setColCount(2); + } + binding.staggeredOrGridOptions.setVisibility(View.VISIBLE); + initStaggeredOrGridOptions(); + } else { + preferencesBuilder.setType(PostsLayoutPreferences.PostsLayoutType.GRID); + if (preferencesBuilder.getColCount() == 1) { + preferencesBuilder.setColCount(2); + } + binding.staggeredOrGridOptions.setVisibility(View.VISIBLE); + initStaggeredOrGridOptions(); + } + } + }); + } + + private void initColCountToggle() { + binding.colCountToggle.check(getSelectedColCountId()); + binding.colCountToggle.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (!isChecked) return; + if (checkedId == R.id.col_count_two) { + preferencesBuilder.setColCount(2); + } else { + preferencesBuilder.setColCount(3); + } + }); + } + + private void initAvatarsToggle() { + binding.showAvatarToggle.setChecked(preferencesBuilder.isAvatarVisible()); + binding.avatarSizeToggle.check(getSelectedAvatarSizeId()); + binding.showAvatarToggle.setOnCheckedChangeListener((buttonView, isChecked) -> { + preferencesBuilder.setAvatarVisible(isChecked); + binding.labelAvatarSize.setVisibility(isChecked ? View.VISIBLE : View.GONE); + binding.avatarSizeToggle.setVisibility(isChecked ? View.VISIBLE : View.GONE); + }); + binding.labelAvatarSize.setVisibility(preferencesBuilder.isAvatarVisible() ? View.VISIBLE : View.GONE); + binding.avatarSizeToggle.setVisibility(preferencesBuilder.isAvatarVisible() ? View.VISIBLE : View.GONE); + binding.avatarSizeToggle.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (!isChecked) return; + if (checkedId == R.id.avatar_size_tiny) { + preferencesBuilder.setProfilePicSize(PostsLayoutPreferences.ProfilePicSize.TINY); + } else if (checkedId == R.id.avatar_size_small) { + preferencesBuilder.setProfilePicSize(PostsLayoutPreferences.ProfilePicSize.SMALL); + } else { + preferencesBuilder.setProfilePicSize(PostsLayoutPreferences.ProfilePicSize.REGULAR); + } + }); + } + + private void initNamesToggle() { + binding.showNamesToggle.setChecked(preferencesBuilder.isNameVisible()); + binding.showNamesToggle.setOnCheckedChangeListener((buttonView, isChecked) -> preferencesBuilder.setNameVisible(isChecked)); + } + + private void initCornersToggle() { + binding.cornersToggle.check(getSelectedCornersId()); + binding.cornersToggle.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (!isChecked) return; + if (checkedId == R.id.corners_round) { + preferencesBuilder.setHasRoundedCorners(true); + return; + } + preferencesBuilder.setHasRoundedCorners(false); + }); + } + + private void initGapToggle() { + binding.showGapToggle.setChecked(preferencesBuilder.getHasGap()); + binding.showGapToggle.setOnCheckedChangeListener((buttonView, isChecked) -> preferencesBuilder.setHasGap(isChecked)); + } + + private int getSelectedLayoutId() { + switch (preferencesBuilder.getType()) { + case STAGGERED_GRID: + return R.id.layout_staggered; + case LINEAR: + return R.id.layout_linear; + default: + case GRID: + return R.id.layout_grid; + } + } + + private int getSelectedColCountId() { + switch (preferencesBuilder.getColCount()) { + case 2: + return R.id.col_count_two; + case 3: + default: + return R.id.col_count_three; + } + } + + private int getSelectedCornersId() { + if (preferencesBuilder.getHasRoundedCorners()) { + return R.id.corners_round; + } + return R.id.corners_square; + } + + private int getSelectedAvatarSizeId() { + switch (preferencesBuilder.getProfilePicSize()) { + case TINY: + return R.id.avatar_size_tiny; + case SMALL: + return R.id.avatar_size_small; + case REGULAR: + default: + return R.id.avatar_size_regular; + } + } + + public interface OnApplyListener { + void onApply(final PostsLayoutPreferences preferences); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java new file mode 100644 index 0000000..6db8a87 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java @@ -0,0 +1,212 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.DialogFragment; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.imagepipeline.image.ImageInfo; + +// import java.io.File; + +import awais.instagrabber.R; +import awais.instagrabber.customviews.drawee.AnimatedZoomableController; +import awais.instagrabber.customviews.drawee.DoubleTapGestureListener; +import awais.instagrabber.databinding.DialogProfilepicBinding; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.UserRepository; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class ProfilePicDialogFragment extends DialogFragment { + private static final String TAG = "ProfilePicDlgFragment"; + + private long id; + private String name; + private String fallbackUrl; + + private boolean isLoggedIn; + private DialogProfilepicBinding binding; + private String url; + + public static ProfilePicDialogFragment getInstance(final long id, final String name, final String fallbackUrl) { + final Bundle args = new Bundle(); + args.putLong("id", id); + args.putString("name", name); + args.putString("fallbackUrl", fallbackUrl); + final ProfilePicDialogFragment fragment = new ProfilePicDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + public ProfilePicDialogFragment() {} + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + binding = DialogProfilepicBinding.inflate(inflater, container, false); + final String cookie = settingsHelper.getString(Constants.COOKIE); + isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + return binding.getRoot(); + } + + @NonNull + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + return dialog; + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + int width = ViewGroup.LayoutParams.MATCH_PARENT; + int height = ViewGroup.LayoutParams.MATCH_PARENT; + window.setLayout(width, height); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + init(); + fetchAvatar(); + } + + private void init() { + final Bundle arguments = getArguments(); + if (arguments == null) { + dismiss(); + return; + } + id = arguments.getLong("id"); + name = arguments.getString("name"); + fallbackUrl = arguments.getString("fallbackUrl"); + binding.download.setOnClickListener(v -> { + final Context context = getContext(); + if (context == null) return; + // if (ContextCompat.checkSelfPermission(context, DownloadUtils.PERMS[0]) == PackageManager.PERMISSION_GRANTED) { + downloadProfilePicture(); + // return; + // } + // requestPermissions(DownloadUtils.PERMS, 8020); + }); + } + + @Override + public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == 8020 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadProfilePicture(); + } + } + + private void fetchAvatar() { + if (isLoggedIn) { + final UserRepository repository = UserRepository.Companion.getInstance(); + repository.getUserInfo(id, CoroutineUtilsKt.getContinuation((user, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + final Context context = getContext(); + if (context == null) { + dismiss(); + return; + } + Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); + dismiss(); + return; + } + if (user != null) { + final String url = user.getHDProfilePicUrl(); + if (TextUtils.isEmpty(url)) { + final Context context = getContext(); + if (context == null) return; + Toast.makeText(context, R.string.no_profile_pic_found, Toast.LENGTH_LONG).show(); + return; + } + setupPhoto(url); + } + }), Dispatchers.getIO())); + } else setupPhoto(fallbackUrl); + } + + private void setupPhoto(final String result) { + if (TextUtils.isEmpty(result)) url = fallbackUrl; + else url = result; + final DraweeController controller = Fresco + .newDraweeControllerBuilder() + .setUri(url) + .setOldController(binding.imageViewer.getController()) + .setControllerListener(new BaseControllerListener() { + @Override + public void onFailure(final String id, final Throwable throwable) { + super.onFailure(id, throwable); + binding.download.setVisibility(View.GONE); + binding.progressView.setVisibility(View.GONE); + } + + @Override + public void onFinalImageSet(final String id, + final ImageInfo imageInfo, + final Animatable animatable) { + super.onFinalImageSet(id, imageInfo, animatable); + binding.download.setVisibility(View.VISIBLE); + binding.progressView.setVisibility(View.GONE); + } + }) + .build(); + binding.imageViewer.setController(controller); + final AnimatedZoomableController zoomableController = (AnimatedZoomableController) binding.imageViewer.getZoomableController(); + zoomableController.setMaxScaleFactor(3f); + zoomableController.setGestureZoomEnabled(true); + zoomableController.setEnabled(true); + binding.imageViewer.setZoomingEnabled(true); + final DoubleTapGestureListener tapListener = new DoubleTapGestureListener(binding.imageViewer); + binding.imageViewer.setTapListener(tapListener); + } + + private void downloadProfilePicture() { + if (url == null) return; + // final File dir = new File(Environment.getExternalStorageDirectory(), "Download"); + final Context context = getContext(); + if (context == null) return; + // if (dir.exists() || dir.mkdirs()) { + // + // } + final String fileName = name + '_' + System.currentTimeMillis() + ".jpg"; + // final File saveFile = new File(dir, fileName); + final DocumentFile downloadDir = DownloadUtils.getDownloadDir(); + final DocumentFile saveFile = downloadDir.createFile(Utils.mimeTypeMap.getMimeTypeFromExtension("jpg"), fileName); + DownloadUtils.download(context, url, saveFile); + // return; + // Toast.makeText(context, R.string.downloader_error_creating_folder, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/RestoreBackupDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/RestoreBackupDialogFragment.java new file mode 100644 index 0000000..513a6f4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/RestoreBackupDialogFragment.java @@ -0,0 +1,193 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.MediaStore; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.inputmethod.InputMethodManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import awais.instagrabber.databinding.DialogRestoreBackupBinding; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.ExportImportUtils; +import awais.instagrabber.utils.PasswordUtils.IncorrectPasswordException; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static android.app.Activity.RESULT_OK; + +public class RestoreBackupDialogFragment extends DialogFragment { + private static final String TAG = RestoreBackupDialogFragment.class.getSimpleName(); + private static final int STORAGE_PERM_REQUEST_CODE = 8020; + private static final int OPEN_FILE_REQUEST_CODE = 1; + + private OnResultListener onResultListener; + + private DialogRestoreBackupBinding binding; + private boolean isEncrypted; + private Uri uri; + + public RestoreBackupDialogFragment() {} + + public RestoreBackupDialogFragment(final OnResultListener onResultListener) { + this.onResultListener = onResultListener; + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + binding = DialogRestoreBackupBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + return dialog; + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + final int height = ViewGroup.LayoutParams.WRAP_CONTENT; + final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); + window.setLayout(width, height); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + init(); + } + + @Override + public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + if (data == null || data.getData() == null) return; + if (resultCode != RESULT_OK || requestCode != OPEN_FILE_REQUEST_CODE) return; + final Context context = getContext(); + if (context == null) return; + isEncrypted = ExportImportUtils.isEncrypted(context, data.getData()); + if (isEncrypted) { + binding.passwordGroup.setVisibility(View.VISIBLE); + binding.passwordGroup.post(() -> { + binding.etPassword.requestFocus(); + binding.etPassword.post(() -> { + final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.showSoftInput(binding.etPassword, InputMethodManager.SHOW_IMPLICIT); + }); + binding.btnRestore.setEnabled(!TextUtils.isEmpty(binding.etPassword.getText())); + }); + } else { + binding.passwordGroup.setVisibility(View.GONE); + binding.btnRestore.setEnabled(true); + } + uri = data.getData(); + AppExecutors.INSTANCE.getMainThread().execute(() -> { + Cursor c = null; + try { + String[] projection = {MediaStore.Files.FileColumns.DISPLAY_NAME}; + final ContentResolver contentResolver = context.getContentResolver(); + c = contentResolver.query(uri, projection, null, null, null); + if (c != null) { + while (c.moveToNext()) { + final String displayName = c.getString(0); + binding.filePath.setText(displayName); + } + } + } catch (Exception e) { + Log.e(TAG, "onActivityResult: ", e); + } finally { + if (c != null) { + c.close(); + } + } + }); + } + + private void init() { + final Context context = getContext(); + if (context == null) return; + binding.btnRestore.setEnabled(false); + binding.btnRestore.setOnClickListener(v -> new Handler(Looper.getMainLooper()).post(() -> { + if (uri == null) return; + int flags = 0; + if (binding.cbFavorites.isChecked()) { + flags |= ExportImportUtils.FLAG_FAVORITES; + } + if (binding.cbSettings.isChecked()) { + flags |= ExportImportUtils.FLAG_SETTINGS; + } + if (binding.cbAccounts.isChecked()) { + flags |= ExportImportUtils.FLAG_COOKIES; + } + final Editable text = binding.etPassword.getText(); + if (isEncrypted && text == null) return; + try { + ExportImportUtils.importData( + context, + flags, + uri, + !isEncrypted ? null : text.toString(), + result -> { + if (onResultListener != null) { + onResultListener.onResult(result); + } + dismiss(); + } + ); + } catch (IncorrectPasswordException e) { + binding.passwordField.setError("Incorrect password"); + } + })); + binding.etPassword.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {} + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + binding.btnRestore.setEnabled(!TextUtils.isEmpty(s)); + binding.passwordField.setError(null); + } + + @Override + public void afterTextChanged(final Editable s) {} + }); + final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.setType("*/*"); + startActivityForResult(intent, OPEN_FILE_REQUEST_CODE); + + } + + public interface OnResultListener { + void onResult(boolean result); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/TabOrderPreferenceDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/TabOrderPreferenceDialogFragment.java new file mode 100644 index 0000000..ba6fbab --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/TabOrderPreferenceDialogFragment.java @@ -0,0 +1,277 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Canvas; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.common.collect.ImmutableList; + +import java.util.List; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectUsersAdapter; +import awais.instagrabber.adapters.TabsAdapter; +import awais.instagrabber.adapters.viewholder.TabViewHolder; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.models.Tab; +import awais.instagrabber.utils.NavigationHelperKt; +import awais.instagrabber.utils.Utils; +import kotlin.Pair; + +import static androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG; +import static androidx.recyclerview.widget.ItemTouchHelper.DOWN; +import static androidx.recyclerview.widget.ItemTouchHelper.UP; + +public class TabOrderPreferenceDialogFragment extends DialogFragment { + private Callback callback; + private Context context; + private List tabsInPref; + private ItemTouchHelper itemTouchHelper; + private AlertDialog dialog; + private List newOrderTabs; + private List newOtherTabs; + + private final TabsAdapter.TabAdapterCallback tabAdapterCallback = new TabsAdapter.TabAdapterCallback() { + @Override + public void onStartDrag(final TabViewHolder viewHolder) { + if (itemTouchHelper == null || viewHolder == null) return; + itemTouchHelper.startDrag(viewHolder); + } + + @Override + public void onOrderChange(final List newOrderTabs) { + if (newOrderTabs == null || tabsInPref == null || dialog == null) return; + TabOrderPreferenceDialogFragment.this.newOrderTabs = newOrderTabs; + setSaveButtonState(newOrderTabs); + } + + @Override + public void onAdd(final Tab tab) { + // Add this tab to newOrderTabs + newOrderTabs = ImmutableList.builder() + .addAll(newOrderTabs) + .add(tab) + .build(); + // Remove this tab from newOtherTabs + if (newOtherTabs != null) { + newOtherTabs = newOtherTabs.stream() + .filter(t -> !t.equals(tab)) + .collect(Collectors.toList()); + } + setSaveButtonState(newOrderTabs); + // submit these tab lists to adapter + if (adapter == null) return; + adapter.submitList(newOrderTabs, newOtherTabs, () -> list.postDelayed(() -> adapter.notifyDataSetChanged(), 300)); + } + + @Override + public void onRemove(final Tab tab) { + // Remove this tab from newOrderTabs + newOrderTabs = newOrderTabs.stream() + .filter(t -> !t.equals(tab)) + .collect(Collectors.toList()); + // Add this tab to newOtherTabs + if (newOtherTabs != null) { + newOtherTabs = ImmutableList.builder() + .addAll(newOtherTabs) + .add(tab) + .build(); + } + setSaveButtonState(newOrderTabs); + // submit these tab lists to adapter + if (adapter == null) return; + adapter.submitList(newOrderTabs, newOtherTabs, () -> list.postDelayed(() -> { + adapter.notifyDataSetChanged(); + if (tab.getNavigationRootId() == R.id.direct_messages_nav_graph) { + final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( + 111, 0, R.string.dm_remove_warning, R.string.ok, 0, 0 + ); + dialogFragment.show(getChildFragmentManager(), "dm_warning_dialog"); + } + }, 500)); + } + + private void setSaveButtonState(final List newOrderTabs) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE) + .setEnabled(!newOrderTabs.equals(tabsInPref)); + } + }; + private final SimpleCallback simpleCallback = new SimpleCallback(UP | DOWN, 0) { + private int movePosition = RecyclerView.NO_POSITION; + + @Override + public int getMovementFlags(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder) { + if (viewHolder instanceof DirectUsersAdapter.HeaderViewHolder) return 0; + if (viewHolder instanceof TabViewHolder && !((TabViewHolder) viewHolder).isDraggable()) return 0; + return super.getMovementFlags(recyclerView, viewHolder); + } + + @Override + public void onChildDraw(@NonNull final Canvas c, + @NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder, + final float dX, + final float dY, + final int actionState, + final boolean isCurrentlyActive) { + if (actionState != ACTION_STATE_DRAG) { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + return; + } + final TabsAdapter adapter = (TabsAdapter) recyclerView.getAdapter(); + if (adapter == null) { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + return; + } + // Do not allow dragging into 'Other tabs' category + float edgeY = dY; + final int lastPosition = adapter.getCurrentCount() - 1; + final View view = viewHolder.itemView; + // final int topEdge = recyclerView.getTop(); + final int bottomEdge = view.getHeight() * adapter.getCurrentCount() - view.getBottom(); + // if (movePosition == 0 && dY < topEdge) { + // edgeY = topEdge; + // } else + if (movePosition >= lastPosition && dY >= bottomEdge) { + edgeY = bottomEdge; + } + super.onChildDraw(c, recyclerView, viewHolder, dX, edgeY, actionState, isCurrentlyActive); + } + + @Override + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder, + @NonNull final RecyclerView.ViewHolder target) { + final TabsAdapter adapter = (TabsAdapter) recyclerView.getAdapter(); + if (adapter == null) return false; + movePosition = target.getBindingAdapterPosition(); + if (movePosition >= adapter.getCurrentCount()) { + return false; + } + final int from = viewHolder.getBindingAdapterPosition(); + final int to = target.getBindingAdapterPosition(); + adapter.moveItem(from, to); + // adapter.notifyItemMoved(from, to); + return true; + } + + @Override + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int direction) {} + + @Override + public void onSelectedChanged(@Nullable final RecyclerView.ViewHolder viewHolder, final int actionState) { + super.onSelectedChanged(viewHolder, actionState); + if (!(viewHolder instanceof TabViewHolder)) { + movePosition = RecyclerView.NO_POSITION; + return; + } + if (actionState == ACTION_STATE_DRAG) { + ((TabViewHolder) viewHolder).setDragging(true); + movePosition = viewHolder.getBindingAdapterPosition(); + } + } + + @Override + public void clearView(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + ((TabViewHolder) viewHolder).setDragging(false); + movePosition = RecyclerView.NO_POSITION; + } + }; + private TabsAdapter adapter; + private RecyclerView list; + + public static TabOrderPreferenceDialogFragment newInstance() { + final Bundle args = new Bundle(); + final TabOrderPreferenceDialogFragment fragment = new TabOrderPreferenceDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + public TabOrderPreferenceDialogFragment() {} + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + try { + callback = (Callback) getParentFragment(); + } catch (ClassCastException e) { + // throw new ClassCastException("Calling fragment must implement TabOrderPreferenceDialogFragment.Callback interface"); + } + this.context = context; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + return new MaterialAlertDialogBuilder(context) + .setView(createView()) + .setPositiveButton(R.string.save, (d, w) -> { + final boolean hasChanged = newOrderTabs != null && !newOrderTabs.equals(tabsInPref); + if (hasChanged) { + saveNewOrder(); + } + if (callback == null) return; + callback.onSave(hasChanged); + }) + .setNegativeButton(R.string.cancel, (dialog, which) -> { + if (callback == null) return; + callback.onCancel(); + }) + .create(); + } + + private void saveNewOrder() { + final String newOrderString = newOrderTabs + .stream() + .map(tab -> NavigationHelperKt.getNavGraphNameForNavRootId(tab.getNavigationRootId())) + .collect(Collectors.joining(",")); + Utils.settingsHelper.putString(PreferenceKeys.PREF_TAB_ORDER, newOrderString); + } + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (!(dialog instanceof AlertDialog)) return; + this.dialog = (AlertDialog) dialog; + this.dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); + } + + @NonNull + private View createView() { + list = new RecyclerView(context); + list.setLayoutManager(new LinearLayoutManager(context)); + itemTouchHelper = new ItemTouchHelper(simpleCallback); + itemTouchHelper.attachToRecyclerView(list); + adapter = new TabsAdapter(tabAdapterCallback); + list.setAdapter(adapter); + final Pair, List> navTabListPair = NavigationHelperKt.getLoggedInNavTabs(context); + tabsInPref = navTabListPair.getFirst(); + // initially set newOrderTabs and newOtherTabs same as current tabs + newOrderTabs = navTabListPair.getFirst(); + newOtherTabs = navTabListPair.getSecond(); + adapter.submitList(navTabListPair.getFirst(), navTabListPair.getSecond()); + return list; + } + + public interface Callback { + void onSave(final boolean orderHasChanged); + + void onCancel(); + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/TimeSettingsDialog.java b/app/src/main/java/awais/instagrabber/dialogs/TimeSettingsDialog.java new file mode 100755 index 0000000..c7238bb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/TimeSettingsDialog.java @@ -0,0 +1,199 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.AdapterView; +import android.widget.CompoundButton; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.DialogTimeSettingsBinding; +import awais.instagrabber.utils.DateUtils; +import awais.instagrabber.utils.LocaleUtils; +import awais.instagrabber.utils.TextUtils; + +public final class TimeSettingsDialog extends DialogFragment implements AdapterView.OnItemSelectedListener, CompoundButton.OnCheckedChangeListener, + View.OnClickListener, TextWatcher { + private DialogTimeSettingsBinding binding; + private final LocalDateTime magicDate; + private DateTimeFormatter currentFormat; + private String selectedFormat; + private final boolean customDateTimeFormatEnabled; + private final String customDateTimeFormat; + private final String dateTimeSelection; + private final boolean swapDateTimeEnabled; + private final OnConfirmListener onConfirmListener; + + public TimeSettingsDialog(final boolean customDateTimeFormatEnabled, + final String customDateTimeFormat, + final String dateTimeSelection, + final boolean swapDateTimeEnabled, + final OnConfirmListener onConfirmListener) { + this.customDateTimeFormatEnabled = customDateTimeFormatEnabled; + this.customDateTimeFormat = customDateTimeFormat; + this.dateTimeSelection = dateTimeSelection; + this.swapDateTimeEnabled = swapDateTimeEnabled; + this.onConfirmListener = onConfirmListener; + magicDate = LocalDateTime.ofInstant( + Instant.now(), + ZoneId.systemDefault() + ); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = DialogTimeSettingsBinding.inflate(inflater, container, false); + + binding.cbCustomFormat.setOnCheckedChangeListener(this); + binding.cbCustomFormat.setChecked(customDateTimeFormatEnabled); + binding.cbSwapTimeDate.setChecked(swapDateTimeEnabled); + binding.customFormatEditText.setText(customDateTimeFormat); + + final String[] dateTimeFormat = dateTimeSelection.split(";"); // output = time;separator;date + binding.spTimeFormat.setSelection(Integer.parseInt(dateTimeFormat[0])); + binding.spSeparator.setSelection(Integer.parseInt(dateTimeFormat[1])); + binding.spDateFormat.setSelection(Integer.parseInt(dateTimeFormat[2])); + + binding.cbSwapTimeDate.setOnCheckedChangeListener(this); + + refreshTimeFormat(); + + binding.spTimeFormat.setOnItemSelectedListener(this); + binding.spDateFormat.setOnItemSelectedListener(this); + binding.spSeparator.setOnItemSelectedListener(this); + + binding.customFormatEditText.addTextChangedListener(this); + binding.btnConfirm.setOnClickListener(this); + binding.customFormatField.setEndIconOnClickListener(this); + + return binding.getRoot(); + } + + private void refreshTimeFormat() { + final boolean isCustom = binding.cbCustomFormat.isChecked(); + if (isCustom) { + final Editable text = binding.customFormatEditText.getText(); + if (text != null) { + selectedFormat = text.toString(); + } + } else { + final String sepStr = String.valueOf(binding.spSeparator.getSelectedItem()); + final String timeStr = String.valueOf(binding.spTimeFormat.getSelectedItem()); + final String dateStr = String.valueOf(binding.spDateFormat.getSelectedItem()); + + final boolean isSwapTime = binding.cbSwapTimeDate.isChecked(); + final boolean isBlankSeparator = binding.spSeparator.getSelectedItemPosition() <= 0; + + selectedFormat = (isSwapTime ? dateStr : timeStr) + + (isBlankSeparator ? " " : " '" + sepStr + "' ") + + (isSwapTime ? timeStr : dateStr); + } + + binding.btnConfirm.setEnabled(true); + try { + currentFormat = DateTimeFormatter.ofPattern(selectedFormat, LocaleUtils.getCurrentLocale()); + if (isCustom) { + final boolean valid = !TextUtils.isEmpty(selectedFormat) && DateUtils.checkFormatterValid(currentFormat); + binding.customFormatField.setError(valid ? null :getString(R.string.invalid_format)); + if (!valid) { + binding.btnConfirm.setEnabled(false); + } + } + binding.timePreview.setText(magicDate.format(currentFormat)); + } catch (Exception e) { + binding.btnConfirm.setEnabled(false); + binding.timePreview.setText(null); + } + } + + @Override + public void onItemSelected(final AdapterView p, final View v, final int pos, final long id) { + refreshTimeFormat(); + } + + @Override + public void onCheckedChanged(final CompoundButton buttonView, final boolean isChecked) { + if (buttonView == binding.cbCustomFormat) { + binding.customFormatField.setVisibility(isChecked ? View.VISIBLE : View.GONE); + binding.customFormatField.setEnabled(isChecked); + + binding.spTimeFormat.setEnabled(!isChecked); + binding.spDateFormat.setEnabled(!isChecked); + binding.spSeparator.setEnabled(!isChecked); + binding.cbSwapTimeDate.setEnabled(!isChecked); + } + refreshTimeFormat(); + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + refreshTimeFormat(); + } + + @Override + public void onClick(final View v) { + if (v == binding.btnConfirm) { + if (onConfirmListener != null) { + onConfirmListener.onConfirm( + binding.cbCustomFormat.isChecked(), + binding.spTimeFormat.getSelectedItemPosition(), + binding.spSeparator.getSelectedItemPosition(), + binding.spDateFormat.getSelectedItemPosition(), + selectedFormat, + binding.cbSwapTimeDate.isChecked()); + } + dismiss(); + } else if (v == binding.customFormatField.findViewById(R.id.text_input_end_icon)) { + binding.customPanel.setVisibility( + binding.customPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE + ); + + } + } + + public interface OnConfirmListener { + void onConfirm(boolean isCustomFormat, + int spTimeFormatSelectedItemPosition, + int spSeparatorSelectedItemPosition, + int spDateFormatSelectedItemPosition, + final String selectedFormat, + final boolean swapDateTime); + } + + @Override + public void onNothingSelected(final AdapterView parent) { } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { } + + @Override + public void afterTextChanged(final Editable s) { } + + @Override + public void onResume() { + super.onResume(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final Window window = dialog.getWindow(); + if (window == null) return; + final WindowManager.LayoutParams params = window.getAttributes(); + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + params.height = ViewGroup.LayoutParams.WRAP_CONTENT; + window.setAttributes(params); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java b/app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java new file mode 100644 index 0000000..d6d3087 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java @@ -0,0 +1,477 @@ +package awais.instagrabber.fragments; + +import android.animation.ArgbEvaluator; +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.graphics.ColorUtils; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.transition.ChangeBounds; +import androidx.transition.TransitionInflater; +import androidx.transition.TransitionSet; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.imagepipeline.image.ImageInfo; +import com.google.common.collect.ImmutableList; + +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.asyncs.SavedPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentCollectionPostsBinding; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.PostItemType; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.saved.SavedCollection; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.CollectionService; +import awais.instagrabber.webservices.ServiceCallback; + +public class CollectionPostsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "CollectionPostsFragment"; + + private MainActivity fragmentActivity; + private FragmentCollectionPostsBinding binding; + private CoordinatorLayout root; + private boolean shouldRefresh = true; + private SavedCollection savedCollection; + private ActionMode actionMode; + private Set selectedFeedModels; + private CollectionService collectionService; + private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_SAVED_POSTS_LAYOUT); + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.posts.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.saved_collection_select_menu, new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.posts.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, + final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (CollectionPostsFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + DownloadUtils.download(context, ImmutableList.copyOf(CollectionPostsFragment.this.selectedFeedModels)); + binding.posts.endSelection(); + } + return false; + } + }); + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel) { + openPostDialog(feedModel, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + try { + final NavDirections commentsAction = CollectionPostsFragmentDirections.actionToComments( + feedModel.getCode(), + feedModel.getPk(), + user.getPk() + ); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(commentsAction); + } catch (Exception e) { + Log.e(TAG, "onCommentsClick: ", e); + } + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { + final Context context = getContext(); + if (context == null) return; + DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); + } + + @Override + public void onHashtagClick(final String hashtag) { + try { + final NavDirections action = CollectionPostsFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onLocationClick(final Media feedModel) { + final Location location = feedModel.getLocation(); + if (location == null) return; + try { + final NavDirections action = CollectionPostsFragmentDirections.actionToLocation(location.getPk()); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onLocationClick: ", e); + } + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(final Media feedModel, final int position) { + try { + final NavDirections action = CollectionPostsFragmentDirections.actionToPost(feedModel, position); + NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + } + }; + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + CollectionPostsFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + final TransitionSet transitionSet = new TransitionSet(); + final Context context = getContext(); + if (context == null) return; + transitionSet.addTransition(new ChangeBounds()) + .addTransition(TransitionInflater.from(context).inflateTransition(android.R.transition.move)) + .setDuration(200); + setSharedElementEnterTransition(transitionSet); + postponeEnterTransition(); + setHasOptionsMenu(true); + final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); + final long userId = CookieUtils.getUserIdFromCookie(cookie); + final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + collectionService = CollectionService.getInstance(deviceUuid, csrfToken, userId); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentCollectionPostsBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.collection_posts_menu, menu); + final MenuItem deleteMenu = menu.findItem(R.id.delete); + if (deleteMenu != null) + deleteMenu.setVisible(savedCollection.getCollectionType().equals("MEDIA")); + final MenuItem editMenu = menu.findItem(R.id.edit); + if (editMenu != null) + editMenu.setVisible(savedCollection.getCollectionType().equals("MEDIA")); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } else if (item.getItemId() == R.id.delete) { + final Context context = getContext(); + if (context == null) return false; + new AlertDialog.Builder(context) + .setTitle(R.string.are_you_sure) + .setMessage(R.string.delete_collection_note) + .setPositiveButton(R.string.confirm, (d, w) -> collectionService.deleteCollection( + savedCollection.getCollectionId(), + new ServiceCallback() { + @Override + public void onSuccess(final String result) { + SavedCollectionsFragment.pleaseRefresh = true; + NavHostFragment.findNavController(CollectionPostsFragment.this).navigateUp(); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error deleting collection", t); + try { + final Context context = getContext(); + if (context == null) return; + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (final Throwable ignored) {} + } + })) + .setNegativeButton(R.string.cancel, null) + .show(); + } else if (item.getItemId() == R.id.edit) { + final Context context = getContext(); + if (context == null) return false; + final EditText input = new EditText(context); + new AlertDialog.Builder(context) + .setTitle(R.string.edit_collection) + .setView(input) + .setPositiveButton(R.string.confirm, (d, w) -> collectionService.editCollectionName( + savedCollection.getCollectionId(), + input.getText().toString(), + new ServiceCallback() { + @Override + public void onSuccess(final String result) { + binding.collapsingToolbarLayout.setTitle(input.getText().toString()); + SavedCollectionsFragment.pleaseRefresh = true; + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error editing collection", t); + try { + final Context context = getContext(); + if (context == null) return; + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (final Throwable ignored) {} + } + })) + .setNegativeButton(R.string.cancel, null) + .show(); + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onResume() { + super.onResume(); + fragmentActivity.setToolbar(binding.toolbar, this); + } + + @Override + public void onRefresh() { + binding.posts.refresh(); + } + + @Override + public void onStop() { + super.onStop(); + fragmentActivity.resetToolbar(this); + } + + private void init() { + if (getArguments() == null) return; + final CollectionPostsFragmentArgs fragmentArgs = CollectionPostsFragmentArgs.fromBundle(getArguments()); + savedCollection = fragmentArgs.getSavedCollection(); + setupToolbar(fragmentArgs.getTitleColor(), fragmentArgs.getBackgroundColor()); + setupPosts(); + } + + private void setupToolbar(final int titleColor, final int backgroundColor) { + if (savedCollection == null) { + return; + } + binding.cover.setTransitionName("collection-" + savedCollection.getCollectionId()); + fragmentActivity.setToolbar(binding.toolbar, this); + binding.collapsingToolbarLayout.setTitle(savedCollection.getCollectionName()); + final int collapsedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0xFF); + final int expandedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0x99); + binding.collapsingToolbarLayout.setExpandedTitleColor(expandedTitleTextColor); + binding.collapsingToolbarLayout.setCollapsedTitleTextColor(collapsedTitleTextColor); + binding.collapsingToolbarLayout.setContentScrimColor(backgroundColor); + final Drawable navigationIcon = binding.toolbar.getNavigationIcon(); + final Drawable overflowIcon = binding.toolbar.getOverflowIcon(); + if (navigationIcon != null && overflowIcon != null) { + final Drawable navDrawable = navigationIcon.mutate(); + final Drawable overflowDrawable = overflowIcon.mutate(); + navDrawable.setAlpha(0xFF); + overflowDrawable.setAlpha(0xFF); + final ArgbEvaluator argbEvaluator = new ArgbEvaluator(); + binding.appBarLayout.addOnOffsetChangedListener((appBarLayout, verticalOffset) -> { + final int totalScrollRange = appBarLayout.getTotalScrollRange(); + final float current = totalScrollRange + verticalOffset; + final float fraction = current / totalScrollRange; + final int tempColor = (int) argbEvaluator.evaluate(fraction, collapsedTitleTextColor, expandedTitleTextColor); + navDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); + overflowDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); + + }); + } + final GradientDrawable gd = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{Color.TRANSPARENT, backgroundColor}); + binding.background.setBackground(gd); + setupCover(); + } + + private void setupCover() { + final String coverUrl = ResponseBodyUtils.getImageUrl(savedCollection.getCoverMediaList().get(0)); + final DraweeController controller = Fresco + .newDraweeControllerBuilder() + .setOldController(binding.cover.getController()) + .setUri(coverUrl) + .setControllerListener(new BaseControllerListener() { + + @Override + public void onFailure(final String id, final Throwable throwable) { + super.onFailure(id, throwable); + startPostponedEnterTransition(); + } + + @Override + public void onFinalImageSet(final String id, + @Nullable final ImageInfo imageInfo, + @Nullable final Animatable animatable) { + startPostponedEnterTransition(); + } + }) + .build(); + binding.cover.setController(controller); + } + + private void setupPosts() { + binding.posts.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new SavedPostFetchService(0, PostItemType.COLLECTION, true, savedCollection.getCollectionId())) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + } + + private void updateSwipeRefreshState() { + AppExecutors.INSTANCE.getMainThread().execute(() -> + binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) + ); + } + + private void navigateToProfile(final String username) { + try { + final NavDirections action = CollectionPostsFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "navigateToProfile: ", e); + } + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + Constants.PREF_SAVED_POSTS_LAYOUT, + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); + }); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.kt b/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.kt new file mode 100644 index 0000000..6bb75bd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.kt @@ -0,0 +1,110 @@ +package awais.instagrabber.fragments + +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import awais.instagrabber.R +import awais.instagrabber.adapters.FavoritesAdapter +import awais.instagrabber.databinding.FragmentFavoritesBinding +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.models.enums.FavoriteType +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.viewmodels.FavoritesViewModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class FavoritesFragment : Fragment() { + private var shouldRefresh = true + + private lateinit var binding: FragmentFavoritesBinding + private lateinit var root: RecyclerView + private lateinit var adapter: FavoritesAdapter + + private val favoritesViewModel: FavoritesViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + if (this::root.isInitialized) { + shouldRefresh = false + return root + } + binding = FragmentFavoritesBinding.inflate(layoutInflater) + root = binding.root + binding.favoriteList.layoutManager = LinearLayoutManager(context) + return root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (!shouldRefresh) return + init() + shouldRefresh = false + } + + override fun onPause() { + super.onPause() + adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT + } + + override fun onResume() { + super.onResume() + if (!this::adapter.isInitialized) return + // refresh list every time in onViewStateRestored since it is cheaper than implementing pull down to refresh + favoritesViewModel.list.observe(viewLifecycleOwner, { list: List? -> + adapter.submitList(list) { + adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.ALLOW + } + }) + } + + private fun init() { + adapter = FavoritesAdapter({ model: Favorite -> + when (model.type) { + FavoriteType.USER -> { + try { + val username = model.query ?: return@FavoritesAdapter + val actionToProfile = FavoritesFragmentDirections.actionToProfile().apply { this.username = username } + findNavController().navigate(actionToProfile) + } catch (e: Exception) { + Log.e(TAG, "init: ", e) + } + } + FavoriteType.LOCATION -> { + try { + val locationId = model.query ?: return@FavoritesAdapter + val actionToLocation = FavoritesFragmentDirections.actionToLocation(locationId.toLong()) + findNavController().navigate(actionToLocation) + } catch (e: Exception) { + Log.e(TAG, "init: ", e) + } + } + FavoriteType.HASHTAG -> { + try { + val hashtag = model.query ?: return@FavoritesAdapter + val actionToHashtag = FavoritesFragmentDirections.actionToHashtag(hashtag) + findNavController().navigate(actionToHashtag) + } catch (e: Exception) { + Log.e(TAG, "init: ", e) + } + } + else -> { + } + } + }, { model: Favorite -> + // delete + val context = context ?: return@FavoritesAdapter false + MaterialAlertDialogBuilder(context) + .setMessage(getString(R.string.quick_access_confirm_delete, model.query)) + .setPositiveButton(R.string.yes) { d: DialogInterface, _: Int -> favoritesViewModel.delete(model) { d.dismiss() } } + .setNegativeButton(R.string.no, null) + .show() + true + }) + binding.favoriteList.adapter = adapter + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/FollowViewerFragment.kt b/app/src/main/java/awais/instagrabber/fragments/FollowViewerFragment.kt new file mode 100644 index 0000000..f0f9aa3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/FollowViewerFragment.kt @@ -0,0 +1,238 @@ +package awais.instagrabber.fragments + +import android.os.Bundle +import android.view.* +import androidx.appcompat.app.ActionBar +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import awais.instagrabber.R +import awais.instagrabber.adapters.FollowAdapter +import awais.instagrabber.customviews.helpers.RecyclerLazyLoader +import awais.instagrabber.databinding.FragmentFollowersViewerBinding +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.viewmodels.FollowViewModel +import thoughtbot.expandableadapter.ExpandableGroup +import java.util.* + +class FollowViewerFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener { + private var isFollowersList = false + private var isCompare = false + private var shouldRefresh = true + private var searching = false + private var username: String? = null + private var namePost: String? = null + private var type = 0 + private var root: SwipeRefreshLayout? = null + private var adapter: FollowAdapter? = null + private lateinit var lazyLoader: RecyclerLazyLoader + private lateinit var fragmentActivity: AppCompatActivity + private lateinit var viewModel: FollowViewModel + private lateinit var binding: FragmentFollowersViewerBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + fragmentActivity = activity as AppCompatActivity + viewModel = ViewModelProvider(this).get(FollowViewModel::class.java) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + if (root != null) { + shouldRefresh = false + return root!! + } + binding = FragmentFollowersViewerBinding.inflate(layoutInflater) + root = binding.root + return root!! + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (!shouldRefresh) return + init() + shouldRefresh = false + } + + private fun init() { + val args = arguments ?: return + val fragmentArgs = FollowViewerFragmentArgs.fromBundle(args) + viewModel.userId.value = fragmentArgs.profileId + isFollowersList = fragmentArgs.isFollowersList + username = fragmentArgs.username + namePost = username + setTitle(username) + binding.swipeRefreshLayout.setOnRefreshListener(this) + if (isCompare) listCompare() else listFollows() + viewModel.fetch(isFollowersList, null) + } + + override fun onResume() { + super.onResume() + setTitle(username) + setSubtitle(type) + } + + private fun setTitle(title: String?) { + val actionBar: ActionBar = fragmentActivity.supportActionBar ?: return + actionBar.title = title + } + + private fun setSubtitle(subtitleRes: Int) { + val actionBar: ActionBar = fragmentActivity.supportActionBar ?: return + actionBar.setSubtitle(subtitleRes) + } + + override fun onRefresh() { + lazyLoader.resetState() + viewModel.clearProgress() + if (isCompare) listCompare() + else viewModel.fetch(isFollowersList, null) + } + + override fun onDestroy() { + fragmentActivity.supportActionBar?.subtitle = null + super.onDestroy() + } + + private fun listFollows() { + viewModel.comparison.removeObservers(viewLifecycleOwner) + viewModel.status.removeObservers(viewLifecycleOwner) + type = if (isFollowersList) R.string.followers_type_followers else R.string.followers_type_following + setSubtitle(type) + val layoutManager = LinearLayoutManager(context) + lazyLoader = RecyclerLazyLoader(layoutManager) { _, totalItemsCount -> + binding.swipeRefreshLayout.isRefreshing = true + val liveData = if (searching) viewModel.search(isFollowersList) + else viewModel.fetch(isFollowersList, null) + liveData.observe(viewLifecycleOwner) { + binding.swipeRefreshLayout.isRefreshing = it.status != Resource.Status.SUCCESS + layoutManager.scrollToPosition(totalItemsCount) + } + } + binding.rvFollow.addOnScrollListener(lazyLoader) + binding.rvFollow.layoutManager = layoutManager + viewModel.getList(isFollowersList).observe(viewLifecycleOwner) { + binding.swipeRefreshLayout.isRefreshing = false + refreshAdapter(it, null, null, null) + } + } + + private fun listCompare() { + viewModel.getList(isFollowersList).removeObservers(viewLifecycleOwner) + binding.rvFollow.clearOnScrollListeners() + binding.swipeRefreshLayout.isRefreshing = true + setSubtitle(R.string.followers_compare) + viewModel.status.observe(viewLifecycleOwner) {} + viewModel.comparison.observe(viewLifecycleOwner) { + if (it != null) { + binding.swipeRefreshLayout.isRefreshing = false + refreshAdapter(null, it.first, it.second, it.third) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.follow, menu) + val menuSearch = menu.findItem(R.id.action_search) + val searchView = menuSearch.actionView as SearchView + searchView.queryHint = resources.getString(R.string.action_search) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return false + } + + override fun onQueryTextChange(query: String): Boolean { + if (query.isEmpty()) { + if (!isCompare && searching) { + viewModel.setQuery(null, isFollowersList) + viewModel.getSearch().removeObservers(viewLifecycleOwner) + viewModel.getList(isFollowersList).observe(viewLifecycleOwner) { + refreshAdapter(it, null, null, null) + } + } + searching = false + return true + } + searching = true + if (isCompare && adapter != null) { + adapter!!.filter.filter(query) + return true + } + viewModel.getList(isFollowersList).removeObservers(viewLifecycleOwner) + binding.swipeRefreshLayout.isRefreshing = true + viewModel.setQuery(query, isFollowersList) + viewModel.getSearch().observe(viewLifecycleOwner) { + binding.swipeRefreshLayout.isRefreshing = false + refreshAdapter(it, null, null, null) + } + return true + } + }) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId != R.id.action_compare) return super.onOptionsItemSelected(item) + binding.rvFollow.adapter = null + if (isCompare) { + isCompare = false + listFollows() + } else { + isCompare = true + listCompare() + } + return true + } + + private fun refreshAdapter( + followModels: List?, + allFollowing: List?, + followingModels: List?, + followersModels: List? + ) { + val groups: ArrayList = ArrayList(1) + if (isCompare && followingModels != null && followersModels != null && allFollowing != null) { + if (followingModels.isNotEmpty()) groups.add( + ExpandableGroup( + getString( + R.string.followers_not_following, + username + ), followingModels + ) + ) + if (followersModels.isNotEmpty()) groups.add( + ExpandableGroup( + getString( + R.string.followers_not_follower, + namePost + ), followersModels + ) + ) + if (allFollowing.isNotEmpty()) groups.add( + ExpandableGroup( + getString(R.string.followers_both_following), + allFollowing + ) + ) + } else if (followModels != null) { + groups.add(ExpandableGroup(getString(type), followModels)) + } else return + adapter = FollowAdapter({ v -> + val tag = v.tag + if (tag is User) { + findNavController().navigate(FollowViewerFragmentDirections.actionToProfile().setUsername(tag.username)) + } + }, groups).also { + it.toggleGroup(0) + binding.rvFollow.adapter = it + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/HashTagFragment.java b/app/src/main/java/awais/instagrabber/fragments/HashTagFragment.java new file mode 100644 index 0000000..0e5ba71 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/HashTagFragment.java @@ -0,0 +1,590 @@ +package awais.instagrabber.fragments; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Typeface; +import android.os.Bundle; +import android.os.Handler; +import android.text.SpannableStringBuilder; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; +import com.google.common.collect.ImmutableList; + +import java.time.LocalDateTime; +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.asyncs.HashtagPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentHashtagBinding; +import awais.instagrabber.databinding.LayoutHashtagDetailsBinding; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.repositories.FavoriteRepository; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.models.enums.FollowingType; +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.ServiceCallback; +import awais.instagrabber.webservices.TagsService; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class HashTagFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "HashTagFragment"; + + private MainActivity fragmentActivity; + private FragmentHashtagBinding binding; + private CoordinatorLayout root; + private boolean shouldRefresh = true; + private boolean opening = false; + private String hashtag; + private Hashtag hashtagModel = null; + private ActionMode actionMode; + // private StoriesRepository storiesRepository; + private boolean isLoggedIn; + private TagsService tagsService; + private GraphQLRepository graphQLRepository; + // private boolean storiesFetching; + private Set selectedFeedModels; + private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_HASHTAG_POSTS_LAYOUT); + private LayoutHashtagDetailsBinding hashtagDetailsBinding; + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.posts.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.multi_select_download_menu, + new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.posts.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (HashTagFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + DownloadUtils.download(context, ImmutableList.copyOf(HashTagFragment.this.selectedFeedModels)); + binding.posts.endSelection(); + return true; + } + return false; + } + }); + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel) { + openPostDialog(feedModel, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + try { + final NavDirections commentsAction = HashTagFragmentDirections.actionToComments( + feedModel.getCode(), + feedModel.getCode(), + user.getPk() + ); + NavHostFragment.findNavController(HashTagFragment.this).navigate(commentsAction); + } catch (Exception e) { + Log.e(TAG, "onCommentsClick: ", e); + } + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { + final Context context = getContext(); + if (context == null) return; + DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); + } + + @Override + public void onHashtagClick(final String hashtag) { + try { + final NavDirections action = HashTagFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(HashTagFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onLocationClick(final Media media) { + final Location location = media.getLocation(); + if (location == null) return; + try { + final NavDirections action = HashTagFragmentDirections.actionToLocation(location.getPk()); + NavHostFragment.findNavController(HashTagFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onLocationClick: ", e); + } + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(@NonNull final Media feedModel, final int position) { + if (opening) return; + final User user = feedModel.getUser(); + if (user == null) return; + if (TextUtils.isEmpty(user.getUsername())) { + // this only happens for anons + opening = true; + final String code = feedModel.getCode(); + if (code == null) return; + graphQLRepository.fetchPost(code, CoroutineUtilsKt.getContinuation((media, throwable) -> { + opening = false; + if (throwable != null) { + Log.e(TAG, "Error", throwable); + return; + } + if (media == null) return; + AppExecutors.INSTANCE.getMainThread().execute(() -> openPostDialog(media, position)); + }, Dispatchers.getIO())); + return; + } + opening = true; + try { + final NavDirections action = HashTagFragmentDirections.actionToPost(feedModel, position); + NavHostFragment.findNavController(HashTagFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + opening = false; + } + }; + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + HashTagFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + private final ServiceCallback cb = new ServiceCallback() { + @Override + public void onSuccess(final Hashtag result) { + hashtagModel = result; + binding.swipeRefreshLayout.setRefreshing(false); + setHashtagDetails(); + } + + @Override + public void onFailure(final Throwable t) { + setHashtagDetails(); + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + final String cookie = settingsHelper.getString(Constants.COOKIE); + isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + tagsService = isLoggedIn ? TagsService.getInstance() : null; + // storiesRepository = isLoggedIn ? StoriesRepository.Companion.getInstance() : null; + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + setHasOptionsMenu(true); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentHashtagBinding.inflate(inflater, container, false); + root = binding.getRoot(); + hashtagDetailsBinding = binding.header; + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onRefresh() { + binding.posts.refresh(); + // fetchStories(); + } + + @Override + public void onResume() { + super.onResume(); + fragmentActivity.setToolbar(binding.toolbar, this); + setTitle(); + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.topic_posts_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onStop() { + super.onStop(); + fragmentActivity.resetToolbar(this); + } + + private void init() { + if (getArguments() == null) return; + final HashTagFragmentArgs fragmentArgs = HashTagFragmentArgs.fromBundle(getArguments()); + hashtag = fragmentArgs.getHashtag(); + if (hashtag.charAt(0) == '#') hashtag = hashtag.substring(1); + fetchHashtagModel(); + } + + private void fetchHashtagModel() { + binding.swipeRefreshLayout.setRefreshing(true); + if (isLoggedIn) tagsService.fetch(hashtag, cb); + else graphQLRepository.fetchTag(hashtag, CoroutineUtilsKt.getContinuation((hashtag1, throwable) -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + AppExecutors.INSTANCE.getMainThread().execute(() -> cb.onSuccess(hashtag1)); + }, Dispatchers.getIO())); + } + + private void setupPosts() { + binding.posts.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new HashtagPostFetchService(hashtagModel, isLoggedIn)) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + // binding.posts.addOnScrollListener(new RecyclerView.OnScrollListener() { + // @Override + // public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + // super.onScrolled(recyclerView, dx, dy); + // final boolean canScrollVertically = recyclerView.canScrollVertically(-1); + // final MotionScene.Transition transition = root.getTransition(R.id.transition); + // if (transition != null) { + // transition.setEnable(!canScrollVertically); + // } + // } + // }); + } + + private void setHashtagDetails() { + if (hashtagModel == null) { + try { + Toast.makeText(getContext(), R.string.error_loading_hashtag, Toast.LENGTH_SHORT).show(); + binding.swipeRefreshLayout.setEnabled(false); + } catch (Exception ignored) {} + return; + } + setTitle(); + setupPosts(); + if (isLoggedIn) { + hashtagDetailsBinding.btnFollowTag.setVisibility(View.VISIBLE); + hashtagDetailsBinding.btnFollowTag.setText(hashtagModel.getFollowing() == FollowingType.FOLLOWING + ? R.string.unfollow + : R.string.follow); + hashtagDetailsBinding.btnFollowTag.setChipIconResource(hashtagModel.getFollowing() == FollowingType.FOLLOWING + ? R.drawable.ic_outline_person_add_disabled_24 + : R.drawable.ic_outline_person_add_24); + hashtagDetailsBinding.btnFollowTag.setOnClickListener(v -> { + final String cookie = settingsHelper.getString(Constants.COOKIE); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + final long userId = CookieUtils.getUserIdFromCookie(cookie); + final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); + if (csrfToken != null && userId != 0) { + hashtagDetailsBinding.btnFollowTag.setClickable(false); + tagsService.changeFollow( + hashtagModel.getFollowing() == FollowingType.FOLLOWING ? "unfollow" : "follow", + hashtag, + csrfToken, + userId, + deviceUuid, + new ServiceCallback() { + @Override + public void onSuccess(final Boolean result) { + hashtagDetailsBinding.btnFollowTag.setClickable(true); + if (!result) { + Log.e(TAG, "onSuccess: result is false"); + Snackbar.make(root, R.string.downloader_unknown_error, BaseTransientBottomBar.LENGTH_LONG) + .show(); + return; + } + hashtagDetailsBinding.btnFollowTag.setText(R.string.unfollow); + hashtagDetailsBinding.btnFollowTag.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24); + } + + @Override + public void onFailure(@NonNull final Throwable t) { + hashtagDetailsBinding.btnFollowTag.setClickable(true); + Log.e(TAG, "onFailure: ", t); + final String message = t.getMessage(); + Snackbar.make( + root, + message != null ? message : getString(R.string.downloader_unknown_error), + BaseTransientBottomBar.LENGTH_LONG) + .show(); + } + }); + } + }); + } else { + hashtagDetailsBinding.btnFollowTag.setVisibility(View.GONE); + } + hashtagDetailsBinding.favChip.setVisibility(View.VISIBLE); + final Context context = getContext(); + if (context == null) return; + final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(context); + favoriteRepository.getFavorite( + hashtag, + FavoriteType.HASHTAG, + CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null || favorite == null) { + hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); + hashtagDetailsBinding.favChip.setText(R.string.add_to_favorites); + return; + } + favoriteRepository.insertOrUpdateFavorite( + new Favorite( + favorite.getId(), + hashtag, + FavoriteType.HASHTAG, + hashtagModel.getName(), + "res:/" + R.drawable.ic_hashtag, + favorite.getDateAdded() + ), + CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onSuccess: ", throwable1); + return; + } + hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); + hashtagDetailsBinding.favChip.setText(R.string.favorite_short); + }), Dispatchers.getIO()) + ); + }), Dispatchers.getIO()) + ); + hashtagDetailsBinding.favChip.setOnClickListener(v -> favoriteRepository.getFavorite( + hashtag, + FavoriteType.HASHTAG, + CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "setHashtagDetails: ", throwable); + return; + } + if (favorite == null) { + favoriteRepository.insertOrUpdateFavorite( + new Favorite( + 0, + hashtag, + FavoriteType.HASHTAG, + hashtagModel.getName(), + "res:/" + R.drawable.ic_hashtag, + LocalDateTime.now() + ), + CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onDataNotAvailable: ", throwable1); + return; + } + hashtagDetailsBinding.favChip.setText(R.string.favorite_short); + hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); + showSnackbar(getString(R.string.added_to_favs)); + }), Dispatchers.getIO()) + ); + return; + } + favoriteRepository.deleteFavorite( + hashtag, + FavoriteType.HASHTAG, + CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onSuccess: ", throwable1); + return; + } + hashtagDetailsBinding.favChip.setText(R.string.add_to_favorites); + hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); + showSnackbar(getString(R.string.removed_from_favs)); + }), Dispatchers.getIO()) + ); + }), Dispatchers.getIO()) + ) + ); + hashtagDetailsBinding.mainHashtagImage.setImageURI("res:/" + R.drawable.ic_hashtag); + final String postCount = String.valueOf(hashtagModel.getMediaCount()); + final SpannableStringBuilder span = new SpannableStringBuilder(getResources().getQuantityString( + R.plurals.main_posts_count_inline, + hashtagModel.getMediaCount() > 2000000000L ? 2000000000 + : Long.valueOf(hashtagModel.getMediaCount()).intValue(), + postCount) + ); + span.setSpan(new RelativeSizeSpan(1.2f), 0, postCount.length(), 0); + span.setSpan(new StyleSpan(Typeface.BOLD), 0, postCount.length(), 0); + hashtagDetailsBinding.mainTagPostCount.setText(span); + hashtagDetailsBinding.mainTagPostCount.setVisibility(View.VISIBLE); + // hashtagDetailsBinding.mainHashtagImage.setOnClickListener(v -> { + // if (!hasStories) return; + // // show stories + // final NavDirections action = HashTagFragmentDirections + // .actionHashtagFragmentToStoryViewerFragment(StoryViewerOptions.forHashtag(hashtagModel.getName())); + // NavHostFragment.findNavController(this).navigate(action); + // }); + } + + private void showSnackbar(final String message) { + @SuppressLint("ShowToast") final Snackbar snackbar = Snackbar.make(root, message, BaseTransientBottomBar.LENGTH_LONG); + snackbar.setAction(R.string.ok, v1 -> snackbar.dismiss()) + .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) + .setAnchorView(fragmentActivity.getBottomNavView()) + .show(); + } + + private void setTitle() { + final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle('#' + hashtag); + } + } + + private void updateSwipeRefreshState() { + AppExecutors.INSTANCE.getMainThread().execute(() -> + binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) + ); + } + + private void navigateToProfile(final String username) { + try { + final NavDirections action = HashTagFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "navigateToProfile: ", e); + } + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + Constants.PREF_HASHTAG_POSTS_LAYOUT, + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); + }); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/LikesViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/LikesViewerFragment.java new file mode 100644 index 0000000..c4330b8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/LikesViewerFragment.java @@ -0,0 +1,206 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import java.util.List; + +import awais.instagrabber.adapters.LikesAdapter; +import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; +import awais.instagrabber.databinding.FragmentLikesBinding; +import awais.instagrabber.repositories.responses.GraphQLUserListFetchResponse; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.MediaRepository; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public final class LikesViewerFragment extends BottomSheetDialogFragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = LikesViewerFragment.class.getSimpleName(); + + private FragmentLikesBinding binding; + private RecyclerLazyLoader lazyLoader; + private MediaRepository mediaRepository; + private GraphQLRepository graphQLRepository; + private boolean isLoggedIn; + private String postId, endCursor; + private boolean isComment; + + private final ServiceCallback> cb = new ServiceCallback>() { + @Override + public void onSuccess(final List result) { + final LikesAdapter likesAdapter = new LikesAdapter(result, v -> { + final Object tag = v.getTag(); + if (tag instanceof User) { + User model = (User) tag; + try { + final NavDirections action = LikesViewerFragmentDirections.actionToProfile().setUsername(model.getUsername()); + NavHostFragment.findNavController(LikesViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onSuccess: ", e); + } + } + }); + binding.rvLikes.setAdapter(likesAdapter); + final Context context = getContext(); + if (context == null) return; + binding.rvLikes.setLayoutManager(new LinearLayoutManager(context)); + binding.rvLikes.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); + binding.swipeRefreshLayout.setRefreshing(false); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error", t); + try { + final Context context = getContext(); + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (Exception ignored) {} + } + }; + + private final ServiceCallback anonCb = new ServiceCallback() { + @Override + public void onSuccess(final GraphQLUserListFetchResponse result) { + endCursor = result.getNextMaxId(); + final LikesAdapter likesAdapter = new LikesAdapter(result.getItems(), v -> { + final Object tag = v.getTag(); + if (tag instanceof User) { + User model = (User) tag; + try { + final NavDirections action = LikesViewerFragmentDirections.actionToProfile().setUsername(model.getUsername()); + NavHostFragment.findNavController(LikesViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onSuccess: ", e); + } + } + }); + binding.rvLikes.setAdapter(likesAdapter); + binding.swipeRefreshLayout.setRefreshing(false); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error", t); + try { + final Context context = getContext(); + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (Exception ignored) {} + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final String cookie = settingsHelper.getString(Constants.COOKIE); + final long userId = CookieUtils.getUserIdFromCookie(cookie); + isLoggedIn = !TextUtils.isEmpty(cookie) && userId != 0; + // final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + if (csrfToken == null) return; + mediaRepository = isLoggedIn ? MediaRepository.Companion.getInstance() : null; + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + // setHasOptionsMenu(true); + } + + @NonNull + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = FragmentLikesBinding.inflate(getLayoutInflater()); + binding.swipeRefreshLayout.setEnabled(false); + binding.swipeRefreshLayout.setNestedScrollingEnabled(false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(); + } + + @Override + public void onRefresh() { + if (isComment && !isLoggedIn) { + lazyLoader.resetState(); + graphQLRepository.fetchCommentLikers( + postId, + null, + CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + anonCb.onFailure(throwable); + return; + } + anonCb.onSuccess(response); + }), Dispatchers.getIO()) + ); + } else { + mediaRepository.fetchLikes( + postId, + isComment, + CoroutineUtilsKt.getContinuation((users, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + //noinspection unchecked + cb.onSuccess((List) users); + }), Dispatchers.getIO()) + ); + } + } + + private void init() { + if (getArguments() == null) return; + final LikesViewerFragmentArgs fragmentArgs = LikesViewerFragmentArgs.fromBundle(getArguments()); + postId = fragmentArgs.getPostId(); + isComment = fragmentArgs.getIsComment(); + binding.swipeRefreshLayout.setOnRefreshListener(this); + binding.swipeRefreshLayout.setRefreshing(true); + if (isComment && !isLoggedIn) { + final Context context = getContext(); + if (context == null) return; + final LinearLayoutManager layoutManager = new LinearLayoutManager(context); + binding.rvLikes.setLayoutManager(layoutManager); + binding.rvLikes.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.HORIZONTAL)); + lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> { + if (!TextUtils.isEmpty(endCursor)) { + graphQLRepository.fetchCommentLikers( + postId, + endCursor, + CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + anonCb.onFailure(throwable); + return; + } + anonCb.onSuccess(response); + }), Dispatchers.getIO()) + ); + } + endCursor = null; + }); + binding.rvLikes.addOnScrollListener(lazyLoader); + } + onRefresh(); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/LocationFragment.java b/app/src/main/java/awais/instagrabber/fragments/LocationFragment.java new file mode 100644 index 0000000..ab862a9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/LocationFragment.java @@ -0,0 +1,582 @@ +package awais.instagrabber.fragments; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; +import com.google.common.collect.ImmutableList; + +import java.time.LocalDateTime; +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.asyncs.LocationPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentLocationBinding; +import awais.instagrabber.databinding.LayoutLocationDetailsBinding; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.repositories.FavoriteRepository; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.LocationService; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class LocationFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "LocationFragment"; + + private MainActivity fragmentActivity; + private FragmentLocationBinding binding; + private CoordinatorLayout root; + private boolean shouldRefresh = true; + private boolean opening = false; + private long locationId; + private Location locationModel; + private ActionMode actionMode; + private GraphQLRepository graphQLRepository; + private LocationService locationService; + private boolean isLoggedIn; + private Set selectedFeedModels; + private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_LOCATION_POSTS_LAYOUT); + private LayoutLocationDetailsBinding locationDetailsBinding; + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.posts.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.posts.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, + final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (LocationFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + DownloadUtils.download(context, ImmutableList.copyOf(LocationFragment.this.selectedFeedModels)); + binding.posts.endSelection(); + return true; + } + return false; + } + }); + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel) { + openPostDialog(feedModel, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + try { + final NavDirections commentsAction = LocationFragmentDirections.actionToComments( + feedModel.getCode(), + feedModel.getPk(), + user.getPk() + ); + NavHostFragment.findNavController(LocationFragment.this).navigate(commentsAction); + } catch (Exception e) { + Log.e(TAG, "onCommentsClick: ", e); + } + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { + final Context context = getContext(); + if (context == null) return; + DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); + } + + @Override + public void onHashtagClick(final String hashtag) { + try { + final NavDirections action = LocationFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(LocationFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onLocationClick(final Media feedModel) { + final Location location = feedModel.getLocation(); + if (location == null) return; + final NavDirections action = LocationFragmentDirections.actionToLocation(location.getPk()); + NavHostFragment.findNavController(LocationFragment.this).navigate(action); + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(@NonNull final Media feedModel, final int position) { + if (opening) return; + final User user = feedModel.getUser(); + if (user == null) return; + if (TextUtils.isEmpty(user.getUsername())) { + opening = true; + final String code = feedModel.getCode(); + if (code == null) return; + graphQLRepository.fetchPost( + code, + CoroutineUtilsKt.getContinuation((media, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + opening = false; + if (throwable != null) { + Log.e(TAG, "Error", throwable); + return; + } + if (media == null) return; + openPostDialog(media, position); + })) + ); + return; + } + opening = true; + try { + final NavDirections action = LocationFragmentDirections.actionToPost(feedModel, position); + NavHostFragment.findNavController(LocationFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + opening = false; + } + }; + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + LocationFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + private final ServiceCallback cb = new ServiceCallback() { + @Override + public void onSuccess(final Location result) { + locationModel = result; + binding.swipeRefreshLayout.setRefreshing(false); + setupLocationDetails(); + } + + @Override + public void onFailure(final Throwable t) { + setupLocationDetails(); + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + final String cookie = settingsHelper.getString(Constants.COOKIE); + isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + locationService = isLoggedIn ? LocationService.getInstance() : null; + // storiesRepository = StoriesRepository.Companion.getInstance(); + graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); + setHasOptionsMenu(true); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentLocationBinding.inflate(inflater, container, false); + root = binding.getRoot(); + locationDetailsBinding = binding.header; + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onRefresh() { + binding.posts.refresh(); + } + + @Override + public void onResume() { + super.onResume(); + fragmentActivity.setToolbar(binding.toolbar, this); + setTitle(); + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.topic_posts_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onStop() { + super.onStop(); + fragmentActivity.resetToolbar(this); + } + + private void init() { + if (getArguments() == null) return; + final LocationFragmentArgs fragmentArgs = LocationFragmentArgs.fromBundle(getArguments()); + locationId = fragmentArgs.getLocationId(); + locationDetailsBinding.favChip.setVisibility(View.GONE); + locationDetailsBinding.btnMap.setVisibility(View.GONE); + setTitle(); + fetchLocationModel(); + } + + private void setupPosts() { + binding.posts.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new LocationPostFetchService(locationModel, isLoggedIn)) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + // binding.posts.addOnScrollListener(new RecyclerView.OnScrollListener() { + // @Override + // public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + // super.onScrolled(recyclerView, dx, dy); + // final boolean canScrollVertically = recyclerView.canScrollVertically(-1); + // final MotionScene.Transition transition = root.getTransition(R.id.transition); + // if (transition != null) { + // transition.setEnable(!canScrollVertically); + // } + // } + // }); + } + + private void fetchLocationModel() { + binding.swipeRefreshLayout.setRefreshing(true); + if (isLoggedIn) locationService.fetch(locationId, cb); + else graphQLRepository.fetchLocation( + locationId, + CoroutineUtilsKt.getContinuation((location, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + cb.onSuccess(location); + })) + ); + } + + private void setupLocationDetails() { + if (locationModel == null) { + try { + Toast.makeText(getContext(), R.string.error_loading_location, Toast.LENGTH_SHORT).show(); + binding.swipeRefreshLayout.setEnabled(false); + } catch (Exception ignored) {} + return; + } + setTitle(); + setupPosts(); + // fetchStories(); + final long locationId = locationModel.getPk(); + // binding.swipeRefreshLayout.setRefreshing(true); + locationDetailsBinding.mainLocationImage.setImageURI("res:/" + R.drawable.ic_location); + // final String postCount = String.valueOf(locationModel.getChildCommentCount()); + // final SpannableStringBuilder span = new SpannableStringBuilder(getResources().getQuantityString(R.plurals.main_posts_count_inline, + // locationModel.getPostCount() > 2000000000L + // ? 2000000000 + // : locationModel.getPostCount().intValue(), + // postCount)); + // span.setSpan(new RelativeSizeSpan(1.2f), 0, postCount.length(), 0); + // span.setSpan(new StyleSpan(Typeface.BOLD), 0, postCount.length(), 0); + // locationDetailsBinding.mainLocPostCount.setText(span); + // locationDetailsBinding.mainLocPostCount.setVisibility(View.VISIBLE); + locationDetailsBinding.locationFullName.setText(locationModel.getName()); + CharSequence biography = locationModel.getAddress() + "\n" + locationModel.getCity(); + // binding.locationBiography.setCaptionIsExpandable(true); + // binding.locationBiography.setCaptionIsExpanded(true); + + final Context context = getContext(); + if (context == null) return; + if (TextUtils.isEmpty(biography)) { + locationDetailsBinding.locationBiography.setVisibility(View.GONE); + } else { + locationDetailsBinding.locationBiography.setVisibility(View.VISIBLE); + locationDetailsBinding.locationBiography.setText(biography); + // locationDetailsBinding.locationBiography.addOnHashtagListener(autoLinkItem -> { + // final NavController navController = NavHostFragment.findNavController(this); + // final Bundle bundle = new Bundle(); + // final String originalText = autoLinkItem.getOriginalText().trim(); + // bundle.putString(ARG_HASHTAG, originalText); + // navController.navigate(R.id.action_global_hashTagFragment, bundle); + // }); + // locationDetailsBinding.locationBiography.addOnMentionClickListener(autoLinkItem -> { + // final String originalText = autoLinkItem.getOriginalText().trim(); + // navigateToProfile(originalText); + // }); + // locationDetailsBinding.locationBiography.addOnEmailClickListener(autoLinkItem -> Utils.openEmailAddress(context, + // autoLinkItem.getOriginalText() + // .trim())); + // locationDetailsBinding.locationBiography + // .addOnURLClickListener(autoLinkItem -> Utils.openURL(context, autoLinkItem.getOriginalText().trim())); + locationDetailsBinding.locationBiography.setOnLongClickListener(v -> { + Utils.copyText(context, biography); + return true; + }); + } + + if (!locationModel.getGeo().startsWith("geo:0.0,0.0?z=17")) { + locationDetailsBinding.btnMap.setVisibility(View.VISIBLE); + locationDetailsBinding.btnMap.setOnClickListener(v -> { + try { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(locationModel.getGeo())); + startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.no_external_map_app, Toast.LENGTH_LONG).show(); + Log.e(TAG, "setupLocationDetails: ", e); + } catch (Exception e) { + Log.e(TAG, "setupLocationDetails: ", e); + } + }); + } else { + locationDetailsBinding.btnMap.setVisibility(View.GONE); + locationDetailsBinding.btnMap.setOnClickListener(null); + } + + final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(context); + locationDetailsBinding.favChip.setVisibility(View.VISIBLE); + favoriteRepository.getFavorite( + String.valueOf(locationId), + FavoriteType.LOCATION, + CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null || favorite == null) { + locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); + locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); + locationDetailsBinding.favChip.setText(R.string.add_to_favorites); + Log.e(TAG, "setupLocationDetails: ", throwable); + return; + } + locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); + locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); + locationDetailsBinding.favChip.setText(R.string.favorite_short); + favoriteRepository.insertOrUpdateFavorite( + new Favorite( + favorite.getId(), + String.valueOf(locationId), + FavoriteType.LOCATION, + locationModel.getName(), + "res:/" + R.drawable.ic_location, + favorite.getDateAdded() + ), + CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onSuccess: ", throwable1); + } + }), Dispatchers.getIO()) + ); + }), Dispatchers.getIO()) + ); + locationDetailsBinding.favChip.setOnClickListener(v -> favoriteRepository.getFavorite( + String.valueOf(locationId), + FavoriteType.LOCATION, + CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "setupLocationDetails: ", throwable); + return; + } + if (favorite == null) { + favoriteRepository.insertOrUpdateFavorite( + new Favorite( + 0, + String.valueOf(locationId), + FavoriteType.LOCATION, + locationModel.getName(), + "res:/" + R.drawable.ic_location, + LocalDateTime.now() + ), + CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onDataNotAvailable: ", throwable1); + return; + } + locationDetailsBinding.favChip.setText(R.string.favorite_short); + locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); + showSnackbar(getString(R.string.added_to_favs)); + }), Dispatchers.getIO()) + ); + return; + } + favoriteRepository.deleteFavorite( + String.valueOf(locationId), + FavoriteType.LOCATION, + CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onSuccess: ", throwable1); + return; + } + locationDetailsBinding.favChip.setText(R.string.add_to_favorites); + locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); + showSnackbar(getString(R.string.removed_from_favs)); + }), Dispatchers.getIO()) + ); + }), Dispatchers.getIO()) + )); + } + + private void showSnackbar(final String message) { + @SuppressLint("ShowToast") final Snackbar snackbar = Snackbar.make(root, message, BaseTransientBottomBar.LENGTH_LONG); + snackbar.setAction(R.string.ok, v1 -> snackbar.dismiss()) + .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) + .setAnchorView(fragmentActivity.getBottomNavView()) + .show(); + } + + private void setTitle() { + final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + if (actionBar != null && locationModel != null) { + actionBar.setTitle(locationModel.getName()); + } + } + + private void updateSwipeRefreshState() { + AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching())); + } + + private void navigateToProfile(final String username) { + try { + final NavDirections action = LocationFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "navigateToProfile: ", e); + } + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + Constants.PREF_LOCATION_POSTS_LAYOUT, + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); + }); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/NotificationsViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/NotificationsViewerFragment.java new file mode 100644 index 0000000..a997bc6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/NotificationsViewerFragment.java @@ -0,0 +1,298 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.RelativeSizeSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NotificationManagerCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.NotificationsAdapter; +import awais.instagrabber.adapters.NotificationsAdapter.OnNotificationClickListener; +import awais.instagrabber.databinding.FragmentNotificationsViewerBinding; +import awais.instagrabber.models.enums.NotificationType; +import awais.instagrabber.repositories.requests.StoryViewerOptions; +import awais.instagrabber.repositories.responses.notification.Notification; +import awais.instagrabber.repositories.responses.notification.NotificationArgs; +import awais.instagrabber.repositories.responses.notification.NotificationImage; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.NotificationViewModel; +import awais.instagrabber.webservices.FriendshipRepository; +import awais.instagrabber.webservices.MediaRepository; +import awais.instagrabber.webservices.NewsService; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +public final class NotificationsViewerFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "NotificationsViewer"; + + private AppCompatActivity fragmentActivity; + private FragmentNotificationsViewerBinding binding; + private SwipeRefreshLayout root; + private boolean shouldRefresh = true; + private NotificationViewModel notificationViewModel; + private FriendshipRepository friendshipRepository; + private MediaRepository mediaRepository; + private NewsService newsService; + private String csrfToken, deviceUuid; + private String type; + private long targetId; + private Context context; + private long userId; + + private final ServiceCallback> cb = new ServiceCallback>() { + @Override + public void onSuccess(final List notificationModels) { + binding.swipeRefreshLayout.setRefreshing(false); + notificationViewModel.getList().postValue(notificationModels); + } + + @Override + public void onFailure(final Throwable t) { + try { + binding.swipeRefreshLayout.setRefreshing(false); + Toast.makeText(getContext(), t.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (Throwable ignored) {} + } + }; + + private final OnNotificationClickListener clickListener = new OnNotificationClickListener() { + @Override + public void onProfileClick(final String username) { + openProfile(username); + } + + @Override + public void onPreviewClick(final Notification model) { + final NotificationImage notificationImage = model.getArgs().getMedia().get(0); + final long mediaId = Long.parseLong(notificationImage.getId().split("_")[0]); + if (model.getType() == NotificationType.RESPONDED_STORY) { + final StoryViewerOptions options = StoryViewerOptions.forStory( + mediaId, + model.getArgs().getUsername() + ); + try { + final NavDirections action = NotificationsViewerFragmentDirections.actionToStory(options); + NavHostFragment.findNavController(NotificationsViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onPreviewClick: ", e); + } + } else { + final AlertDialog alertDialog = new AlertDialog.Builder(context) + .setCancelable(false) + .setView(R.layout.dialog_opening_post) + .create(); + alertDialog.show(); + mediaRepository.fetch( + mediaId, + CoroutineUtilsKt.getContinuation((media, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + alertDialog.dismiss(); + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); + return; + } + try { + final NavDirections action = NotificationsViewerFragmentDirections.actionToPost(media, 0); + NavHostFragment.findNavController(NotificationsViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onSuccess: ", e); + } finally { + alertDialog.dismiss(); + } + }), Dispatchers.getIO()) + ); + } + } + + @Override + public void onNotificationClick(final Notification model) { + if (model == null) return; + final NotificationArgs args = model.getArgs(); + final String username = args.getUsername(); + if (model.getType() == NotificationType.FOLLOW || model.getType() == NotificationType.AYML) { + openProfile(username); + } else { + final SpannableString title = new SpannableString(username + (TextUtils.isEmpty(args.getText()) ? "" : (":\n" + args.getText()))); + title.setSpan(new RelativeSizeSpan(1.23f), 0, username.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + String[] commentDialogList; + if (model.getType() == NotificationType.RESPONDED_STORY) { + commentDialogList = new String[]{ + getString(R.string.open_profile), + getString(R.string.view_story) + }; + } else if (args.getMedia() != null) { + commentDialogList = new String[]{ + getString(R.string.open_profile), + getString(R.string.view_post) + }; + } else if (model.getType() == NotificationType.REQUEST) { + commentDialogList = new String[]{ + getString(R.string.open_profile), + getString(R.string.request_approve), + getString(R.string.request_reject) + }; + } else commentDialogList = null; // shouldn't happen + final Context context = getContext(); + if (context == null) return; + final DialogInterface.OnClickListener profileDialogListener = (dialog, which) -> { + switch (which) { + case 0: + openProfile(username); + break; + case 1: + if (model.getType() == NotificationType.REQUEST) { + friendshipRepository.approve( + csrfToken, + userId, + deviceUuid, + args.getUserId(), + CoroutineUtilsKt.getContinuation( + (response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "approve: onFailure: ", throwable); + return; + } + onRefresh(); + }), + Dispatchers.getIO() + ) + ); + return; + } + clickListener.onPreviewClick(model); + break; + case 2: + friendshipRepository.ignore( + csrfToken, + userId, + deviceUuid, + args.getUserId(), + CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "approve: onFailure: ", throwable); + return; + } + onRefresh(); + }), Dispatchers.getIO()) + ); + break; + } + }; + new AlertDialog.Builder(context) + .setTitle(title) + .setItems(commentDialogList, profileDialogListener) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (AppCompatActivity) requireActivity(); + context = getContext(); + if (context == null) return; + NotificationManagerCompat.from(context.getApplicationContext()).cancel(Constants.ACTIVITY_NOTIFICATION_ID); + final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); + if (TextUtils.isEmpty(cookie)) { + Toast.makeText(context, R.string.activity_notloggedin, Toast.LENGTH_SHORT).show(); + } + userId = CookieUtils.getUserIdFromCookie(cookie); + deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); + csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + friendshipRepository = FriendshipRepository.Companion.getInstance(); + mediaRepository = MediaRepository.Companion.getInstance(); + newsService = NewsService.getInstance(); + } + + @NonNull + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentNotificationsViewerBinding.inflate(getLayoutInflater()); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + init(); + shouldRefresh = false; + } + + private void init() { + final NotificationsViewerFragmentArgs fragmentArgs = NotificationsViewerFragmentArgs.fromBundle(getArguments()); + type = fragmentArgs.getType(); + targetId = fragmentArgs.getTargetId(); + final Context context = getContext(); + CookieUtils.setupCookies(Utils.settingsHelper.getString(Constants.COOKIE)); + binding.swipeRefreshLayout.setOnRefreshListener(this); + notificationViewModel = new ViewModelProvider(this).get(NotificationViewModel.class); + final NotificationsAdapter adapter = new NotificationsAdapter(clickListener); + binding.rvComments.setLayoutManager(new LinearLayoutManager(context)); + binding.rvComments.setAdapter(adapter); + notificationViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); + onRefresh(); + } + + @Override + public void onRefresh() { + binding.swipeRefreshLayout.setRefreshing(true); + final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + switch (type) { + case "notif": + if (actionBar != null) actionBar.setTitle(R.string.action_notif); + newsService.fetchAppInbox(true, cb); + break; + case "ayml": + if (actionBar != null) actionBar.setTitle(R.string.action_ayml); + newsService.fetchSuggestions(csrfToken, deviceUuid, cb); + break; + case "chaining": + if (actionBar != null) actionBar.setTitle(R.string.action_ayml); + newsService.fetchChaining(targetId, cb); + break; + } + } + + private void openProfile(final String username) { + try { + final NavDirections action = NotificationsViewerFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openProfile: ", e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/PostViewV2Fragment.java b/app/src/main/java/awais/instagrabber/fragments/PostViewV2Fragment.java new file mode 100644 index 0000000..7964e68 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/PostViewV2Fragment.java @@ -0,0 +1,1470 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.appcompat.widget.PopupMenu; +import androidx.appcompat.widget.Toolbar; +import androidx.appcompat.widget.TooltipCompat; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavBackStackEntry; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.TransitionManager; +import androidx.viewpager2.widget.ViewPager2; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; +import com.google.android.exoplayer2.ui.StyledPlayerView; +import com.google.android.material.appbar.CollapsingToolbarLayout; +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; +import com.skydoves.balloon.ArrowOrientation; +import com.skydoves.balloon.ArrowPositionRules; +import com.skydoves.balloon.Balloon; +import com.skydoves.balloon.BalloonAnimation; +import com.skydoves.balloon.BalloonHighlightAnimation; +import com.skydoves.balloon.BalloonSizeSpec; +import com.skydoves.balloon.overlay.BalloonOverlayAnimation; +import com.skydoves.balloon.overlay.BalloonOverlayCircle; + +import java.io.Serializable; +import java.util.List; +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.SliderCallbackAdapter; +import awais.instagrabber.adapters.SliderItemsAdapter; +import awais.instagrabber.adapters.viewholder.SliderVideoViewHolder; +import awais.instagrabber.customviews.VerticalImageSpan; +import awais.instagrabber.customviews.VideoPlayerCallbackAdapter; +import awais.instagrabber.customviews.VideoPlayerViewHelper; +import awais.instagrabber.customviews.drawee.AnimatedZoomableController; +import awais.instagrabber.customviews.drawee.DoubleTapGestureListener; +import awais.instagrabber.customviews.drawee.ZoomableController; +import awais.instagrabber.customviews.drawee.ZoomableDraweeView; +import awais.instagrabber.databinding.DialogPostViewBinding; +import awais.instagrabber.databinding.LayoutPostViewBottomBinding; +import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; +import awais.instagrabber.dialogs.EditTextDialogFragment; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Caption; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.MediaCandidate; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.NullSafePair; +import awais.instagrabber.utils.NumberUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.PostViewV2ViewModel; + +import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_SHOWN_COUNT_TOOLTIP; + +public class PostViewV2Fragment extends Fragment implements EditTextDialogFragment.EditTextDialogFragmentCallback { + private static final String TAG = "PostViewV2Fragment"; + // private static final int DETAILS_HIDE_DELAY_MILLIS = 2000; + public static final String ARG_MEDIA = "media"; + public static final String ARG_SLIDER_POSITION = "position"; + + private DialogPostViewBinding binding; + private Context context; + private boolean detailsVisible = true; +// private boolean video; + private VideoPlayerViewHelper videoPlayerViewHelper; + private SliderItemsAdapter sliderItemsAdapter; + private int sliderPosition = -1; + private PostViewV2ViewModel viewModel; + private PopupMenu optionsPopup; + private EditTextDialogFragment editTextDialogFragment; + private boolean wasDeleted; + private MutableLiveData backStackSavedStateCollectionLiveData; + private MutableLiveData backStackSavedStateResultLiveData; + private OnDeleteListener onDeleteListener; + @Nullable + private ViewPager2 sliderParent; + private LayoutPostViewBottomBinding bottom; + private View postView; + private int originalHeight; + private boolean isInFullScreenMode; + private StyledPlayerView playerView; + private int playerViewOriginalHeight; + private Drawable originalRootBackground; + private ColorStateList originalLikeColorStateList; + private ColorStateList originalSaveColorStateList; + private WindowInsetsControllerCompat controller; + + private final Observer backStackSavedStateObserver = result -> { + if (result == null) return; + if (result instanceof String) { + final String collection = (String) result; + handleSaveUnsaveResourceLiveData(viewModel.toggleSave(collection, viewModel.getMedia().getHasViewerSaved())); + } else if ((result instanceof RankedRecipient)) { + // Log.d(TAG, "result: " + result); + final Context context = getContext(); + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show(); + } + viewModel.shareDm((RankedRecipient) result, sliderPosition); + } else if ((result instanceof Set)) { + try { + // Log.d(TAG, "result: " + result); + final Context context = getContext(); + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show(); + } + //noinspection unchecked + viewModel.shareDm((Set) result, sliderPosition); + } catch (Exception e) { + Log.e(TAG, "share: ", e); + } + } + // clear result + backStackSavedStateCollectionLiveData.postValue(null); + backStackSavedStateResultLiveData.postValue(null); + }; + + public void setOnDeleteListener(final OnDeleteListener onDeleteListener) { + if (onDeleteListener == null) return; + this.onDeleteListener = onDeleteListener; + } + + public interface OnDeleteListener { + void onDelete(); + } + + // default constructor for fragment manager + public PostViewV2Fragment() {} + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(PostViewV2ViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + binding = DialogPostViewBinding.inflate(inflater, container, false); + bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); + final MainActivity activity = (MainActivity) getActivity(); + if (activity == null) return null; + controller = new WindowInsetsControllerCompat(activity.getWindow(), activity.getRootView()); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + // postponeEnterTransition(); + init(); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + this.context = context; + } + + @Override + public void onPause() { + super.onPause(); + // wasPaused = true; + if (Utils.settingsHelper.getBoolean(PreferenceKeys.PLAY_IN_BACKGROUND)) return; + final Media media = viewModel.getMedia(); + if (media.getType() == null) return; + switch (media.getType()) { + case MEDIA_TYPE_VIDEO: + if (videoPlayerViewHelper != null) { + videoPlayerViewHelper.pause(); + } + return; + case MEDIA_TYPE_SLIDER: + if (sliderItemsAdapter != null) { + pauseSliderPlayer(); + } + default: + } + } + + @Override + public void onResume() { + super.onResume(); + final NavController navController = NavHostFragment.findNavController(this); + final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); + if (backStackEntry != null) { + backStackSavedStateCollectionLiveData = backStackEntry.getSavedStateHandle().getLiveData("collection"); + backStackSavedStateCollectionLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); + backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); + backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + showSystemUI(); + final Media media = viewModel.getMedia(); + if (media.getType() == null) return; + switch (media.getType()) { + case MEDIA_TYPE_VIDEO: + if (videoPlayerViewHelper != null) { + videoPlayerViewHelper.releasePlayer(); + } + return; + case MEDIA_TYPE_SLIDER: + if (sliderItemsAdapter != null) { + releaseAllSliderPlayers(); + } + default: + } + } + + @Override + public void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + final Media media = viewModel.getMedia(); + if (media.getType() == MediaItemType.MEDIA_TYPE_SLIDER) { + outState.putInt(ARG_SLIDER_POSITION, sliderPosition); + } + } + + private void init() { + final Bundle arguments = getArguments(); + if (arguments == null) { + // dismiss(); + return; + } + final Serializable feedModelSerializable = arguments.getSerializable(ARG_MEDIA); + if (feedModelSerializable == null) { + Log.e(TAG, "onCreate: feedModelSerializable is null"); + // dismiss(); + return; + } + if (!(feedModelSerializable instanceof Media)) { + // dismiss(); + return; + } + final Media media = (Media) feedModelSerializable; + if (media.getType() == MediaItemType.MEDIA_TYPE_SLIDER && sliderPosition == -1) { + sliderPosition = arguments.getInt(ARG_SLIDER_POSITION, 0); + } + viewModel.setMedia(media); + // if (!wasPaused && (sharedProfilePicElement != null || sharedMainPostElement != null)) { + // binding.getRoot().getBackground().mutate().setAlpha(0); + // } + // setProfilePicSharedElement(); + // setupCaptionBottomSheet(); + setupCommonActions(); + setObservers(); + } + + private void setObservers() { + viewModel.getUser().observe(getViewLifecycleOwner(), user -> { + if (user == null) { + binding.profilePic.setVisibility(View.GONE); + binding.title.setVisibility(View.GONE); + binding.subtitle.setVisibility(View.GONE); + return; + } + binding.profilePic.setVisibility(View.VISIBLE); + binding.title.setVisibility(View.VISIBLE); + binding.subtitle.setVisibility(View.VISIBLE); + binding.getRoot().post(() -> setupProfilePic(user)); + binding.getRoot().post(() -> setupTitles(user)); + }); + viewModel.getCaption().observe(getViewLifecycleOwner(), caption -> binding.getRoot().post(() -> setupCaption(caption))); + viewModel.getLocation().observe(getViewLifecycleOwner(), location -> binding.getRoot().post(() -> setupLocation(location))); + viewModel.getDate().observe(getViewLifecycleOwner(), date -> binding.getRoot().post(() -> { + if (date == null) { + bottom.date.setVisibility(View.GONE); + return; + } + bottom.date.setVisibility(View.VISIBLE); + bottom.date.setText(date); + })); + viewModel.getLikeCount().observe(getViewLifecycleOwner(), count -> { + bottom.likesCount.setNumber(getSafeCount(count)); + binding.getRoot().postDelayed(() -> bottom.likesCount.setAnimateChanges(true), 1000); + if (count > 1000 && !Utils.settingsHelper.getBoolean(PREF_SHOWN_COUNT_TOOLTIP)) { + binding.getRoot().postDelayed(this::showCountTooltip, 1000); + } + }); + if (!viewModel.getMedia().getCommentsDisabled()) { + viewModel.getCommentCount().observe(getViewLifecycleOwner(), count -> { + bottom.commentsCount.setNumber(getSafeCount(count)); + binding.getRoot().postDelayed(() -> bottom.commentsCount.setAnimateChanges(true), 1000); + }); + } + viewModel.getViewCount().observe(getViewLifecycleOwner(), count -> { + if (count == null) { + bottom.viewsCount.setVisibility(View.GONE); + return; + } + bottom.viewsCount.setVisibility(View.VISIBLE); + final long safeCount = getSafeCount(count); + final String viewString = getResources().getQuantityString(R.plurals.views_count, (int) safeCount, safeCount); + bottom.viewsCount.setText(viewString); + }); + viewModel.getType().observe(getViewLifecycleOwner(), this::setupPostTypeLayout); + viewModel.getLiked().observe(getViewLifecycleOwner(), this::setLikedResources); + viewModel.getSaved().observe(getViewLifecycleOwner(), this::setSavedResources); + viewModel.getOptions().observe(getViewLifecycleOwner(), options -> binding.getRoot().post(() -> { + setupOptions(options != null && !options.isEmpty()); + createOptionsPopupMenu(); + })); + } + + private void showCountTooltip() { + final Context context = getContext(); + if (context == null) return; + final Rect rect = new Rect(); + bottom.likesCount.getGlobalVisibleRect(rect); + final Balloon balloon = new Balloon.Builder(context) + .setArrowSize(8) + .setArrowOrientation(ArrowOrientation.TOP) + .setArrowPositionRules(ArrowPositionRules.ALIGN_ANCHOR) + .setArrowPosition(0.5f) + .setWidth(BalloonSizeSpec.WRAP) + .setHeight(BalloonSizeSpec.WRAP) + .setPadding(4) + .setTextSize(16) + .setAlpha(0.9f) + .setBalloonAnimation(BalloonAnimation.ELASTIC) + .setBalloonHighlightAnimation(BalloonHighlightAnimation.HEARTBEAT, 0) + .setIsVisibleOverlay(true) + .setOverlayColorResource(R.color.black_a50) + .setOverlayShape(new BalloonOverlayCircle((float) Math.max( + bottom.likesCount.getMeasuredWidth(), + bottom.likesCount.getMeasuredHeight() + ) / 2f)) + .setBalloonOverlayAnimation(BalloonOverlayAnimation.FADE) + .setLifecycleOwner(getViewLifecycleOwner()) + .setTextResource(R.string.click_to_show_full) + .setDismissWhenTouchOutside(false) + .setDismissWhenOverlayClicked(false) + .build(); + balloon.showAlignBottom(bottom.likesCount); + Utils.settingsHelper.putBoolean(PREF_SHOWN_COUNT_TOOLTIP, true); + balloon.setOnBalloonOutsideTouchListener((view, motionEvent) -> { + if (rect.contains((int) motionEvent.getRawX(), (int) motionEvent.getRawY())) { + bottom.likesCount.setShowAbbreviation(false); + } + balloon.dismiss(); + }); + } + + @NonNull + private Long getSafeCount(final Long count) { + Long safeCount = count; + if (count == null) { + safeCount = 0L; + } + return safeCount; + } + + private void setupCommonActions() { + setupLike(); + setupSave(); + setupDownload(); + setupComment(); + setupShare(); + } + + private void setupComment() { + if (!viewModel.hasPk() || viewModel.getMedia().getCommentsDisabled()) { + bottom.comment.setVisibility(View.GONE); + // bottom.commentsCount.setVisibility(View.GONE); + return; + } + bottom.comment.setVisibility(View.VISIBLE); + bottom.comment.setOnClickListener(v -> { + final Media media = viewModel.getMedia(); + final User user = media.getUser(); + if (user == null) return; + final NavController navController = getNavController(); + if (navController == null) return; + try { + final NavDirections action = PostViewV2FragmentDirections.actionToComments(media.getCode(), media.getPk(), user.getPk()); + navController.navigate(action); + } catch (Exception e) { + Log.e(TAG, "setupComment: ", e); + } + }); + TooltipCompat.setTooltipText(bottom.comment, getString(R.string.comment)); + } + + private void setupDownload() { + bottom.download.setOnClickListener(v -> DownloadUtils.showDownloadDialog(context, viewModel.getMedia(), sliderPosition, bottom.download)); + TooltipCompat.setTooltipText(bottom.download, getString(R.string.action_download)); + } + + private void setupLike() { + originalLikeColorStateList = bottom.like.getIconTint(); + final boolean likableMedia = viewModel.hasPk() /*&& viewModel.getMedia().isCommentLikesEnabled()*/; + if (!likableMedia) { + bottom.like.setVisibility(View.GONE); + // bottom.likesCount.setVisibility(View.GONE); + return; + } + if (!viewModel.isLoggedIn()) { + bottom.like.setVisibility(View.GONE); + return; + } + bottom.like.setOnClickListener(v -> { + v.setEnabled(false); + handleLikeUnlikeResourceLiveData(viewModel.toggleLike()); + }); + bottom.like.setOnLongClickListener(v -> { + final NavController navController = getNavController(); + if (navController != null && viewModel.isLoggedIn()) { + try { + final NavDirections action = PostViewV2FragmentDirections.actionToLikes(viewModel.getMedia().getPk(), false); + navController.navigate(action); + } catch (Exception e) { + Log.e(TAG, "setupLike: ", e); + } + return true; + } + return true; + }); + } + + private void handleLikeUnlikeResourceLiveData(@NonNull final LiveData> resource) { + resource.observe(getViewLifecycleOwner(), value -> { + switch (value.status) { + case SUCCESS: + bottom.like.setEnabled(true); + break; + case ERROR: + bottom.like.setEnabled(true); + unsuccessfulLike(); + break; + case LOADING: + bottom.like.setEnabled(false); + break; + } + }); + + } + + private void unsuccessfulLike() { + final int errorTextResId; + final Media media = viewModel.getMedia(); + if (!media.getHasLiked()) { + Log.e(TAG, "like unsuccessful!"); + errorTextResId = R.string.like_unsuccessful; + } else { + Log.e(TAG, "unlike unsuccessful!"); + errorTextResId = R.string.unlike_unsuccessful; + } + final Snackbar snackbar = Snackbar.make(binding.getRoot(), errorTextResId, BaseTransientBottomBar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.ok, null); + snackbar.show(); + } + + private void setLikedResources(final boolean liked) { + final int iconResource; + final ColorStateList tintColorStateList; + final Context context = getContext(); + if (context == null) return; + final Resources resources = context.getResources(); + if (resources == null) return; + if (liked) { + iconResource = R.drawable.ic_like; + tintColorStateList = ColorStateList.valueOf(resources.getColor(R.color.red_600)); + } else { + iconResource = R.drawable.ic_not_liked; + tintColorStateList = originalLikeColorStateList != null ? originalLikeColorStateList + : ColorStateList.valueOf(resources.getColor(R.color.white)); + } + bottom.like.setIconResource(iconResource); + bottom.like.setIconTint(tintColorStateList); + } + + private void setupSave() { + originalSaveColorStateList = bottom.save.getIconTint(); + if (!viewModel.isLoggedIn() || !viewModel.hasPk() || !viewModel.getMedia().getCanViewerSave()) { + bottom.save.setVisibility(View.GONE); + return; + } + bottom.save.setOnClickListener(v -> { + bottom.save.setEnabled(false); + handleSaveUnsaveResourceLiveData(viewModel.toggleSave()); + }); + bottom.save.setOnLongClickListener(v -> { + try { + final NavDirections action = PostViewV2FragmentDirections.actionToSavedCollections().setIsSaving(true); + NavHostFragment.findNavController(this).navigate(action); + return true; + } catch (Exception e) { + Log.e(TAG, "setupSave: ", e); + } + return false; + }); + } + + private void handleSaveUnsaveResourceLiveData(@NonNull final LiveData> resource) { + resource.observe(getViewLifecycleOwner(), value -> { + if (value == null) return; + switch (value.status) { + case SUCCESS: + bottom.save.setEnabled(true); + break; + case ERROR: + bottom.save.setEnabled(true); + unsuccessfulSave(); + break; + case LOADING: + bottom.save.setEnabled(false); + break; + } + }); + } + + private void unsuccessfulSave() { + final int errorTextResId; + final Media media = viewModel.getMedia(); + if (!media.getHasViewerSaved()) { + Log.e(TAG, "save unsuccessful!"); + errorTextResId = R.string.save_unsuccessful; + } else { + Log.e(TAG, "save remove unsuccessful!"); + errorTextResId = R.string.save_remove_unsuccessful; + } + final Snackbar snackbar = Snackbar.make(binding.getRoot(), errorTextResId, Snackbar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.ok, null); + snackbar.show(); + } + + private void setSavedResources(final boolean saved) { + final int iconResource; + final ColorStateList tintColorStateList; + final Context context = getContext(); + if (context == null) return; + final Resources resources = context.getResources(); + if (resources == null) return; + if (saved) { + iconResource = R.drawable.ic_bookmark; + tintColorStateList = ColorStateList.valueOf(resources.getColor(R.color.blue_700)); + } else { + iconResource = R.drawable.ic_round_bookmark_border_24; + tintColorStateList = originalSaveColorStateList != null ? originalSaveColorStateList + : ColorStateList.valueOf(resources.getColor(R.color.white)); + } + bottom.save.setIconResource(iconResource); + bottom.save.setIconTint(tintColorStateList); + } + + private void setupProfilePic(final User user) { + if (user == null) { + binding.profilePic.setImageURI((String) null); + return; + } + final String uri = user.getProfilePicUrl(); + final DraweeController controller = Fresco + .newDraweeControllerBuilder() + .setUri(uri) + .build(); + binding.profilePic.setController(controller); + binding.profilePic.setOnClickListener(v -> navigateToProfile("@" + user.getUsername())); + } + + private void setupTitles(final User user) { + if (user == null) { + binding.title.setVisibility(View.GONE); + binding.subtitle.setVisibility(View.GONE); + return; + } + final String fullName = user.getFullName(); + if (TextUtils.isEmpty(fullName)) { + binding.subtitle.setVisibility(View.GONE); + } else { + binding.subtitle.setVisibility(View.VISIBLE); + binding.subtitle.setText(fullName); + } + setUsername(user); + binding.title.setOnClickListener(v -> navigateToProfile("@" + user.getUsername())); + binding.subtitle.setOnClickListener(v -> navigateToProfile("@" + user.getUsername())); + } + + private void setUsername(final User user) { + final SpannableStringBuilder sb = new SpannableStringBuilder(user.getUsername()); + final int drawableSize = Utils.convertDpToPx(24); + if (user.isVerified()) { + final Context context = getContext(); + if (context == null) return; + final Drawable verifiedDrawable = AppCompatResources.getDrawable(context, R.drawable.verified); + VerticalImageSpan verifiedSpan = null; + if (verifiedDrawable != null) { + final Drawable drawable = verifiedDrawable.mutate(); + drawable.setBounds(0, 0, drawableSize, drawableSize); + verifiedSpan = new VerticalImageSpan(drawable); + } + try { + if (verifiedSpan != null) { + sb.append(" "); + sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + Log.e(TAG, "setUsername: ", e); + } + } + binding.title.setText(sb); + } + + private void setupCaption(final Caption caption) { + if (caption == null || TextUtils.isEmpty(caption.getText())) { + bottom.caption.setVisibility(View.GONE); + bottom.translate.setVisibility(View.GONE); + return; + } + final String postCaption = caption.getText(); + bottom.caption.addOnHashtagListener(autoLinkItem -> { + try { + final String originalText = autoLinkItem.getOriginalText().trim(); + final NavDirections action = PostViewV2FragmentDirections.actionToHashtag(originalText); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "setupCaption: ", e); + } + }); + bottom.caption.addOnMentionClickListener(autoLinkItem -> { + final String originalText = autoLinkItem.getOriginalText().trim(); + navigateToProfile(originalText); + }); + bottom.caption.addOnEmailClickListener(autoLinkItem -> Utils.openEmailAddress(getContext(), autoLinkItem.getOriginalText().trim())); + bottom.caption.addOnURLClickListener(autoLinkItem -> Utils.openURL(getContext(), autoLinkItem.getOriginalText().trim())); + bottom.caption.setOnLongClickListener(v -> { + final Context context = getContext(); + if (context == null) return false; + Utils.copyText(context, postCaption); + return true; + }); + bottom.caption.setText(postCaption); + bottom.translate.setOnClickListener(v -> handleTranslateCaptionResource(viewModel.translateCaption())); + } + + private void handleTranslateCaptionResource(@NonNull final LiveData> data) { + data.observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + switch (resource.status) { + case SUCCESS: + bottom.translate.setVisibility(View.GONE); + bottom.caption.setText(resource.data); + break; + case ERROR: + bottom.translate.setEnabled(true); + String message = resource.message; + if (TextUtils.isEmpty(message)) { + message = getString(R.string.downloader_unknown_error); + } + final Snackbar snackbar = Snackbar.make(binding.getRoot(), message, Snackbar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.ok, null); + snackbar.show(); + break; + case LOADING: + bottom.translate.setEnabled(false); + break; + } + }); + } + + private void setupLocation(final Location location) { + if (location == null || !detailsVisible) { + binding.location.setVisibility(View.GONE); + return; + } + final String locationName = location.getName(); + if (TextUtils.isEmpty(locationName)) return; + binding.location.setText(locationName); + binding.location.setVisibility(View.VISIBLE); + binding.location.setOnClickListener(v -> { + try { + final NavController navController = getNavController(); + if (navController == null) return; + final NavDirections action = PostViewV2FragmentDirections.actionToLocation(location.getPk()); + navController.navigate(action); + } catch (Exception e) { + Log.e(TAG, "setupLocation: ", e); + } + }); + } + + private void setupShare() { + if (!viewModel.hasPk()) { + bottom.share.setVisibility(View.GONE); + return; + } + bottom.share.setVisibility(View.VISIBLE); + TooltipCompat.setTooltipText(bottom.share, getString(R.string.share)); + bottom.share.setOnClickListener(v -> { + final Media media = viewModel.getMedia(); + final User profileModel = media.getUser(); + if (profileModel == null) return; + if (viewModel.isLoggedIn()) { + final Context context = getContext(); + if (context == null) return; + final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, R.style.popupMenuStyle); + final PopupMenu popupMenu = new PopupMenu(themeWrapper, bottom.share); + final Menu menu = popupMenu.getMenu(); + menu.add(0, R.id.share_dm, 0, R.string.share_via_dm); + menu.add(0, R.id.share, 1, R.string.share_link); + popupMenu.setOnMenuItemClickListener(item -> { + final int itemId = item.getItemId(); + if (itemId == R.id.share_dm) { + if (profileModel.isPrivate()) Toast.makeText(context, R.string.share_private_post, Toast.LENGTH_SHORT).show(); + final PostViewV2FragmentDirections.ActionToUserSearch actionGlobalUserSearch = PostViewV2FragmentDirections + .actionToUserSearch() + .setTitle(getString(R.string.share)) + .setActionLabel(getString(R.string.send)) + .setShowGroups(true) + .setMultiple(true) + .setSearchMode(UserSearchMode.RAVEN); + final NavController navController = NavHostFragment.findNavController(PostViewV2Fragment.this); + try { + navController.navigate(actionGlobalUserSearch); + } catch (Exception e) { + Log.e(TAG, "setupShare: ", e); + } + return true; + } else if (itemId == R.id.share) { + shareLink(media, profileModel.isPrivate()); + return true; + } + return false; + }); + popupMenu.show(); + return; + } + shareLink(media, false); + }); + } + + private void shareLink(@NonNull final Media media, final boolean isPrivate) { + final Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND); + sharingIntent.setType("text/plain"); + sharingIntent.putExtra(android.content.Intent.EXTRA_TITLE, + getString(isPrivate ? R.string.share_private_post : R.string.share_public_post)); + sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, "https://instagram.com/p/" + media.getCode()); + startActivity(Intent.createChooser( + sharingIntent, + isPrivate ? getString(R.string.share_private_post) + : getString(R.string.share_public_post) + )); + } + + private void setupPostTypeLayout(final MediaItemType type) { + if (type == null) return; + switch (type) { + case MEDIA_TYPE_IMAGE: + setupPostImage(); + break; + case MEDIA_TYPE_SLIDER: + setupSlider(); + break; + case MEDIA_TYPE_VIDEO: + setupVideo(); + break; + } + } + + private void setupPostImage() { + // binding.mediaCounter.setVisibility(View.GONE); + final Context context = getContext(); + if (context == null) return; + final Resources resources = context.getResources(); + if (resources == null) return; + final Media media = viewModel.getMedia(); + final String imageUrl = ResponseBodyUtils.getImageUrl(media); + if (TextUtils.isEmpty(imageUrl)) return; + final ZoomableDraweeView postImage = new ZoomableDraweeView(context); + postView = postImage; + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(media.getOriginalHeight(), + media.getOriginalWidth(), + (int) (Utils.displayMetrics.heightPixels * 0.8), + Utils.displayMetrics.widthPixels); + originalHeight = widthHeight.second; + final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, + originalHeight); + postImage.setLayoutParams(layoutParams); + postImage.setHierarchy(new GenericDraweeHierarchyBuilder(resources) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + .build()); + + postImage.setController(Fresco.newDraweeControllerBuilder() + .setLowResImageRequest(ImageRequest.fromUri(ResponseBodyUtils.getThumbUrl(media))) + .setImageRequest(ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageUrl)) + .setLocalThumbnailPreviewsEnabled(true) + .build()) + .build()); + final AnimatedZoomableController zoomableController = (AnimatedZoomableController) postImage.getZoomableController(); + zoomableController.setMaxScaleFactor(3f); + zoomableController.setGestureZoomEnabled(true); + zoomableController.setEnabled(true); + postImage.setZoomingEnabled(true); + final DoubleTapGestureListener tapListener = new DoubleTapGestureListener(postImage) { + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + if (!isInFullScreenMode) { + zoomableController.reset(); + hideSystemUI(); + } else { + showSystemUI(); + binding.getRoot().postDelayed(zoomableController::reset, 500); + } + return super.onSingleTapConfirmed(e); + } + }; + postImage.setTapListener(tapListener); + binding.postContainer.addView(postView); + } + + private void setupSlider() { + final Media media = viewModel.getMedia(); + binding.mediaCounter.setVisibility(View.VISIBLE); + final Context context = getContext(); + if (context == null) return; + sliderParent = new ViewPager2(context); + final List carouselMedia = media.getCarouselMedia(); + if (carouselMedia == null) return; + final NullSafePair maxHW = carouselMedia + .stream() + .reduce(new NullSafePair<>(0, 0), + (prev, m) -> { + final int height = m.getOriginalHeight() > prev.first ? m.getOriginalHeight() : prev.first; + final int width = m.getOriginalWidth() > prev.second ? m.getOriginalWidth() : prev.second; + return new NullSafePair<>(height, width); + }, + (p1, p2) -> { + final int height = p1.first > p2.first ? p1.first : p2.first; + final int width = p1.second > p2.second ? p1.second : p2.second; + return new NullSafePair<>(height, width); + }); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(maxHW.first, + maxHW.second, + (int) (Utils.displayMetrics.heightPixels * 0.8), + Utils.displayMetrics.widthPixels); + originalHeight = widthHeight.second; + final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, + originalHeight); + sliderParent.setLayoutParams(layoutParams); + postView = sliderParent; + // binding.contentRoot.addView(sliderParent, 0); + binding.postContainer.addView(postView); + + final boolean hasVideo = media.getCarouselMedia() + .stream() + .anyMatch(postChild -> postChild.getType() == MediaItemType.MEDIA_TYPE_VIDEO); + if (hasVideo) { + final View child = sliderParent.getChildAt(0); + if (child instanceof RecyclerView) { + ((RecyclerView) child).setItemViewCacheSize(media.getCarouselMedia().size()); + ((RecyclerView) child).addRecyclerListener(holder -> { + if (holder instanceof SliderVideoViewHolder) { + ((SliderVideoViewHolder) holder).releasePlayer(); + } + }); + } + } + sliderItemsAdapter = new SliderItemsAdapter(true, new SliderCallbackAdapter() { + @Override + public void onItemClicked(final int position, final Media media, final View view) { + if (media == null + || media.getType() != MediaItemType.MEDIA_TYPE_IMAGE + || !(view instanceof ZoomableDraweeView)) { + return; + } + final ZoomableController zoomableController = ((ZoomableDraweeView) view).getZoomableController(); + if (!(zoomableController instanceof AnimatedZoomableController)) return; + if (!isInFullScreenMode) { + ((AnimatedZoomableController) zoomableController).reset(); + hideSystemUI(); + return; + } + showSystemUI(); + binding.getRoot().postDelayed(((AnimatedZoomableController) zoomableController)::reset, 500); + } + + @Override + public void onPlayerPlay(final int position) { + final FragmentActivity activity = getActivity(); + if (activity == null) return; + Utils.enabledKeepScreenOn(activity); + // if (!detailsVisible || hasBeenToggled) return; + // showPlayerControls(); + } + + @Override + public void onPlayerPause(final int position) { + final FragmentActivity activity = getActivity(); + if (activity == null) return; + Utils.disableKeepScreenOn(activity); + // if (detailsVisible || hasBeenToggled) return; + // toggleDetails(); + } + + @Override + public void onPlayerRelease(final int position) { + final FragmentActivity activity = getActivity(); + if (activity == null) return; + Utils.disableKeepScreenOn(activity); + } + + @Override + public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) { + PostViewV2Fragment.this.playerView = playerView; + if (isFullScreen) { + hideSystemUI(); + return; + } + showSystemUI(); + } + + @Override + public boolean isInFullScreen() { + return isInFullScreenMode; + } + }); + sliderParent.setAdapter(sliderItemsAdapter); + if (sliderPosition >= 0 && sliderPosition < media.getCarouselMedia().size()) { + sliderParent.setCurrentItem(sliderPosition); + } + sliderParent.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + int prevPosition = -1; + + @Override + public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) { + if (prevPosition != -1) { + final View view = sliderParent.getChildAt(0); + if (view instanceof RecyclerView) { + pausePlayerAtPosition(prevPosition, (RecyclerView) view); + pausePlayerAtPosition(position, (RecyclerView) view); + } + } + if (positionOffset == 0) { + prevPosition = position; + } + } + + @Override + public void onPageSelected(final int position) { + final int size = media.getCarouselMedia().size(); + if (position < 0 || position >= size) return; + sliderPosition = position; + final String text = (position + 1) + "/" + size; + binding.mediaCounter.setText(text); + final Media childMedia = media.getCarouselMedia().get(position); +// video = false; +// if (childMedia.getType() == MediaItemType.MEDIA_TYPE_VIDEO) { +// video = true; +// viewModel.setViewCount(childMedia.getViewCount()); +// return; +// } +// viewModel.setViewCount(null); + } + + private void pausePlayerAtPosition(final int position, final RecyclerView view) { + final RecyclerView.ViewHolder viewHolder = view.findViewHolderForAdapterPosition(position); + if (viewHolder instanceof SliderVideoViewHolder) { + ((SliderVideoViewHolder) viewHolder).pause(); + } + } + }); + final String text = "1/" + carouselMedia.size(); + binding.mediaCounter.setText(text); + sliderItemsAdapter.submitList(media.getCarouselMedia()); + sliderParent.setCurrentItem(sliderPosition); + } + + private void pauseSliderPlayer() { + if (sliderParent == null) return; + final int currentItem = sliderParent.getCurrentItem(); + final View view = sliderParent.getChildAt(0); + if (!(view instanceof RecyclerView)) return; + final RecyclerView.ViewHolder viewHolder = ((RecyclerView) view).findViewHolderForAdapterPosition(currentItem); + if (!(viewHolder instanceof SliderVideoViewHolder)) return; + ((SliderVideoViewHolder) viewHolder).pause(); + } + + private void releaseAllSliderPlayers() { + if (sliderParent == null) return; + final View view = sliderParent.getChildAt(0); + if (!(view instanceof RecyclerView)) return; + final int itemCount = sliderItemsAdapter.getItemCount(); + for (int position = itemCount - 1; position >= 0; position--) { + final RecyclerView.ViewHolder viewHolder = ((RecyclerView) view).findViewHolderForAdapterPosition(position); + if (!(viewHolder instanceof SliderVideoViewHolder)) continue; + ((SliderVideoViewHolder) viewHolder).releasePlayer(); + } + } + + private void setupVideo() { +// video = true; + final Media media = viewModel.getMedia(); + binding.mediaCounter.setVisibility(View.GONE); + final Context context = getContext(); + if (context == null) return; + final LayoutVideoPlayerWithThumbnailBinding videoPost = LayoutVideoPlayerWithThumbnailBinding + .inflate(LayoutInflater.from(context), binding.contentRoot, false); + final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) videoPost.getRoot().getLayoutParams(); + final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(media.getOriginalHeight(), + media.getOriginalWidth(), + (int) (Utils.displayMetrics.heightPixels * 0.8), + Utils.displayMetrics.widthPixels); + layoutParams.width = ConstraintLayout.LayoutParams.MATCH_PARENT; + originalHeight = widthHeight.second; + layoutParams.height = originalHeight; + postView = videoPost.getRoot(); + binding.postContainer.addView(postView); + + // final GestureDetector gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + // @Override + // public boolean onSingleTapConfirmed(final MotionEvent e) { + // videoPost.playerView.performClick(); + // return true; + // } + // }); + // videoPost.playerView.setOnTouchListener((v, event) -> { + // gestureDetector.onTouchEvent(event); + // return true; + // }); + final float vol = Utils.settingsHelper.getBoolean(PreferenceKeys.MUTED_VIDEOS) ? 0f : 1f; + final VideoPlayerViewHelper.VideoPlayerCallback videoPlayerCallback = new VideoPlayerCallbackAdapter() { + @Override + public void onThumbnailLoaded() { + startPostponedEnterTransition(); + } + + @Override + public void onPlayerViewLoaded() { + // binding.playerControls.getRoot().setVisibility(View.VISIBLE); + final ViewGroup.LayoutParams layoutParams = videoPost.playerView.getLayoutParams(); + final int requiredWidth = Utils.displayMetrics.widthPixels; + final int resultingHeight = NumberUtils + .getResultingHeight(requiredWidth, media.getOriginalHeight(), media.getOriginalWidth()); + layoutParams.width = requiredWidth; + layoutParams.height = resultingHeight; + videoPost.playerView.requestLayout(); + } + + @Override + public void onPlay() { + final FragmentActivity activity = getActivity(); + if (activity == null) return; + Utils.enabledKeepScreenOn(activity); + // if (detailsVisible) { + // new Handler().postDelayed(() -> toggleDetails(), DETAILS_HIDE_DELAY_MILLIS); + // } + } + + @Override + public void onPause() { + final FragmentActivity activity = getActivity(); + if (activity == null) return; + Utils.disableKeepScreenOn(activity); + } + + @Override + public void onRelease() { + final FragmentActivity activity = getActivity(); + if (activity == null) return; + Utils.disableKeepScreenOn(activity); + } + + @Override + public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) { + PostViewV2Fragment.this.playerView = playerView; + if (isFullScreen) { + hideSystemUI(); + return; + } + showSystemUI(); + } + }; + final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); + String videoUrl = null; + final List videoVersions = media.getVideoVersions(); + if (videoVersions != null && !videoVersions.isEmpty()) { + final MediaCandidate videoVersion = videoVersions.get(0); + if (videoVersion != null) { + videoUrl = videoVersion.getUrl(); + } + } + if (videoUrl != null) { + videoPlayerViewHelper = new VideoPlayerViewHelper( + binding.getRoot().getContext(), + videoPost, + videoUrl, + vol, + aspectRatio, + ResponseBodyUtils.getThumbUrl(media), + true, + videoPlayerCallback); + } + } + + private void setupOptions(final Boolean show) { + if (!show) { + binding.options.setVisibility(View.GONE); + return; + } + binding.options.setVisibility(View.VISIBLE); + binding.options.setOnClickListener(v -> { + if (optionsPopup == null) return; + optionsPopup.show(); + }); + } + + private void createOptionsPopupMenu() { + if (optionsPopup == null) { + final Context context = getContext(); + if (context == null) return; + final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, R.style.popupMenuStyle); + optionsPopup = new PopupMenu(themeWrapper, binding.options); + } else { + optionsPopup.getMenu().clear(); + } + optionsPopup.getMenuInflater().inflate(R.menu.post_view_menu, optionsPopup.getMenu()); + // final Menu menu = optionsPopup.getMenu(); + // final int size = menu.size(); + // for (int i = 0; i < size; i++) { + // final MenuItem item = menu.getItem(i); + // if (item == null) continue; + // if (options.contains(item.getItemId())) continue; + // menu.removeItem(item.getItemId()); + // } + optionsPopup.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.edit_caption) { + showCaptionEditDialog(); + return true; + } + if (itemId == R.id.delete) { + item.setEnabled(false); + final LiveData> resourceLiveData = viewModel.delete(); + handleDeleteResource(resourceLiveData, item); + } + return true; + }); + } + + private void handleDeleteResource(final LiveData> resourceLiveData, final MenuItem item) { + if (resourceLiveData == null) return; + resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(final Resource resource) { + try { + switch (resource.status) { + case SUCCESS: + wasDeleted = true; + if (onDeleteListener != null) { + onDeleteListener.onDelete(); + } + break; + case ERROR: + if (item != null) { + item.setEnabled(true); + } + final Snackbar snackbar = Snackbar.make(binding.getRoot(), + R.string.delete_unsuccessful, + Snackbar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.ok, null); + snackbar.show(); + break; + case LOADING: + if (item != null) { + item.setEnabled(false); + } + break; + } + } finally { + resourceLiveData.removeObserver(this); + } + } + }); + } + + private void showCaptionEditDialog() { + final Caption caption = viewModel.getCaption().getValue(); + final String captionText = caption != null ? caption.getText() : null; + editTextDialogFragment = EditTextDialogFragment + .newInstance(R.string.edit_caption, R.string.confirm, R.string.cancel, captionText); + editTextDialogFragment.show(getChildFragmentManager(), "edit_caption"); + } + + @Override + public void onPositiveButtonClicked(final String caption) { + handleEditCaptionResource(viewModel.updateCaption(caption)); + if (editTextDialogFragment == null) return; + editTextDialogFragment.dismiss(); + editTextDialogFragment = null; + } + + private void handleEditCaptionResource(final LiveData> updateCaption) { + if (updateCaption == null) return; + updateCaption.observe(getViewLifecycleOwner(), resource -> { + final MenuItem item = optionsPopup.getMenu().findItem(R.id.edit_caption); + switch (resource.status) { + case SUCCESS: + if (item != null) { + item.setEnabled(true); + } + break; + case ERROR: + if (item != null) { + item.setEnabled(true); + } + final Snackbar snackbar = Snackbar.make(binding.getRoot(), R.string.edit_unsuccessful, BaseTransientBottomBar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.ok, null); + snackbar.show(); + break; + case LOADING: + if (item != null) { + item.setEnabled(false); + } + break; + } + }); + } + + @Override + public void onNegativeButtonClicked() { + if (editTextDialogFragment == null) return; + editTextDialogFragment.dismiss(); + editTextDialogFragment = null; + } + + private void toggleDetails() { + // final boolean hasBeenToggled = true; + final MainActivity activity = (MainActivity) getActivity(); + if (activity == null) return; + final Media media = viewModel.getMedia(); + binding.getRoot().post(() -> { + TransitionManager.beginDelayedTransition(binding.getRoot()); + if (detailsVisible) { + final Context context = getContext(); + if (context == null) return; + originalRootBackground = binding.getRoot().getBackground(); + final Resources resources = context.getResources(); + if (resources == null) return; + final ColorDrawable colorDrawable = new ColorDrawable(resources.getColor(R.color.black)); + binding.getRoot().setBackground(colorDrawable); + if (postView != null) { + // Make post match parent + final int fullHeight = Utils.displayMetrics.heightPixels - Utils.getStatusBarHeight(context); + postView.getLayoutParams().height = fullHeight; + binding.postContainer.getLayoutParams().height = fullHeight; + if (playerView != null) { + playerViewOriginalHeight = playerView.getLayoutParams().height; + playerView.getLayoutParams().height = fullHeight; + } + } + final BottomNavigationView bottomNavView = activity.getBottomNavView(); + bottomNavView.setVisibility(View.GONE); + detailsVisible = false; + if (media.getUser() != null) { + binding.profilePic.setVisibility(View.GONE); + binding.title.setVisibility(View.GONE); + binding.subtitle.setVisibility(View.GONE); + } + if (media.getLocation() != null) { + binding.location.setVisibility(View.GONE); + } + if (media.getCaption() != null && !TextUtils.isEmpty(media.getCaption().getText())) { + bottom.caption.setVisibility(View.GONE); + bottom.translate.setVisibility(View.GONE); + } + bottom.likesCount.setVisibility(View.GONE); + bottom.commentsCount.setVisibility(View.GONE); + bottom.date.setVisibility(View.GONE); + bottom.comment.setVisibility(View.GONE); + bottom.like.setVisibility(View.GONE); + bottom.save.setVisibility(View.GONE); + bottom.share.setVisibility(View.GONE); + bottom.download.setVisibility(View.GONE); + binding.mediaCounter.setVisibility(View.GONE); + bottom.viewsCount.setVisibility(View.GONE); + final List options = viewModel.getOptions().getValue(); + if (options != null && !options.isEmpty()) { + binding.options.setVisibility(View.GONE); + } + return; + } + if (originalRootBackground != null) { + binding.getRoot().setBackground(originalRootBackground); + } + if (postView != null) { + // Make post height back to original + postView.getLayoutParams().height = originalHeight; + binding.postContainer.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; + if (playerView != null) { + playerView.getLayoutParams().height = playerViewOriginalHeight; + playerView = null; + } + } + final BottomNavigationView bottomNavView = activity.getBottomNavView(); + bottomNavView.setVisibility(View.VISIBLE); + if (media.getUser() != null) { + binding.profilePic.setVisibility(View.VISIBLE); + binding.title.setVisibility(View.VISIBLE); + binding.subtitle.setVisibility(View.VISIBLE); + // binding.topBg.setVisibility(View.VISIBLE); + } + if (media.getLocation() != null) { + binding.location.setVisibility(View.VISIBLE); + } + if (media.getCaption() != null && !TextUtils.isEmpty(media.getCaption().getText())) { + bottom.caption.setVisibility(View.VISIBLE); + bottom.translate.setVisibility(View.VISIBLE); + } + if (viewModel.hasPk()) { + bottom.likesCount.setVisibility(View.VISIBLE); + bottom.date.setVisibility(View.VISIBLE); + // binding.captionParent.setVisibility(View.VISIBLE); + // binding.captionToggle.setVisibility(View.VISIBLE); + bottom.share.setVisibility(View.VISIBLE); + } + if (viewModel.hasPk() && !viewModel.getMedia().getCommentsDisabled()) { + bottom.comment.setVisibility(View.VISIBLE); + bottom.commentsCount.setVisibility(View.VISIBLE); + } + bottom.download.setVisibility(View.VISIBLE); + final List options = viewModel.getOptions().getValue(); + if (options != null && !options.isEmpty()) { + binding.options.setVisibility(View.VISIBLE); + } + if (viewModel.isLoggedIn() && viewModel.hasPk()) { + bottom.like.setVisibility(View.VISIBLE); + bottom.save.setVisibility(View.VISIBLE); + } + // if (video) { + if (media.getType() == MediaItemType.MEDIA_TYPE_VIDEO) { + // binding.playerControlsToggle.setVisibility(View.VISIBLE); + bottom.viewsCount.setVisibility(View.VISIBLE); + } + // if (wasControlsVisible) { + // showPlayerControls(); + // } + if (media.getType() == MediaItemType.MEDIA_TYPE_SLIDER) { + binding.mediaCounter.setVisibility(View.VISIBLE); + } + detailsVisible = true; + }); + } + + private void hideSystemUI() { + if (detailsVisible) { + toggleDetails(); + } + final MainActivity activity = (MainActivity) getActivity(); + if (activity == null) return; + final ActionBar actionBar = activity.getSupportActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + final CollapsingToolbarLayout appbarLayout = activity.getCollapsingToolbarView(); + appbarLayout.setVisibility(View.GONE); + final Toolbar toolbar = activity.getToolbar(); + toolbar.setVisibility(View.GONE); + binding.getRoot().setPadding(binding.getRoot().getPaddingLeft(), + binding.getRoot().getPaddingTop(), + binding.getRoot().getPaddingRight(), + 0); + controller.hide(WindowInsetsCompat.Type.systemBars()); + controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_SWIPE); + isInFullScreenMode = true; + } + + private void showSystemUI() { + if (!detailsVisible) { + toggleDetails(); + } + final MainActivity activity = (MainActivity) getActivity(); + if (activity == null) return; + final ActionBar actionBar = activity.getSupportActionBar(); + if (actionBar != null) { + actionBar.show(); + } + final CollapsingToolbarLayout appbarLayout = activity.getCollapsingToolbarView(); + appbarLayout.setVisibility(View.VISIBLE); + final Toolbar toolbar = activity.getToolbar(); + toolbar.setVisibility(View.VISIBLE); + final Context context = getContext(); + if (context == null) return; + binding.getRoot().setPadding(binding.getRoot().getPaddingLeft(), + binding.getRoot().getPaddingTop(), + binding.getRoot().getPaddingRight(), + Utils.getActionBarHeight(context)); + controller.show(WindowInsetsCompat.Type.systemBars()); + WindowCompat.setDecorFitsSystemWindows(activity.getWindow(), false); + isInFullScreenMode = false; + } + + private void navigateToProfile(final String username) { + final NavController navController = getNavController(); + if (navController == null) return; + final NavDirections actionToProfile = PostViewV2FragmentDirections.actionToProfile().setUsername(username); + navController.navigate(actionToProfile); + } + + @Nullable + private NavController getNavController() { + NavController navController = null; + try { + navController = NavHostFragment.findNavController(this); + } catch (IllegalStateException e) { + Log.e(TAG, "navigateToProfile", e); + } + return navController; + } + + public boolean wasDeleted() { + return wasDeleted; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java b/app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java new file mode 100644 index 0000000..af756f5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java @@ -0,0 +1,199 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.SavedStateHandle; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavBackStackEntry; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.FragmentNavigator; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.SavedCollectionsAdapter; +import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; +import awais.instagrabber.databinding.FragmentSavedCollectionsBinding; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.SavedCollectionsViewModel; +import awais.instagrabber.webservices.ProfileService; +import awais.instagrabber.webservices.ServiceCallback; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = SavedCollectionsFragment.class.getSimpleName(); + public static boolean pleaseRefresh = false; + + private MainActivity fragmentActivity; + private CoordinatorLayout root; + private FragmentSavedCollectionsBinding binding; + private SavedCollectionsViewModel savedCollectionsViewModel; + private boolean shouldRefresh = true; + private boolean isSaving; + private ProfileService profileService; + private SavedCollectionsAdapter adapter; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + profileService = ProfileService.getInstance(); + savedCollectionsViewModel = new ViewModelProvider(fragmentActivity).get(SavedCollectionsViewModel.class); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentSavedCollectionsBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + setupObservers(); + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.saved_collection_menu, menu); + } + + @Override + public void onResume() { + super.onResume(); + if (pleaseRefresh) onRefresh(); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.add) { + final Context context = getContext(); + final EditText input = new EditText(context); + new AlertDialog.Builder(context) + .setTitle(R.string.saved_create_collection) + .setView(input) + .setPositiveButton(R.string.confirm, (d, w) -> { + final String cookie = settingsHelper.getString(Constants.COOKIE); + profileService.createCollection( + input.getText().toString(), + settingsHelper.getString(Constants.DEVICE_UUID), + CookieUtils.getUserIdFromCookie(cookie), + CookieUtils.getCsrfTokenFromCookie(cookie), + new ServiceCallback() { + @Override + public void onSuccess(final String result) { + onRefresh(); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error creating collection", t); + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + } + return false; + } + + private void init() { + setupTopics(); + fetchTopics(null); + final SavedCollectionsFragmentArgs fragmentArgs = SavedCollectionsFragmentArgs.fromBundle(getArguments()); + isSaving = fragmentArgs.getIsSaving(); + } + + @Override + public void onRefresh() { + fetchTopics(null); + } + + public void setupTopics() { + binding.topicsRecyclerView.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(2))); + adapter = new SavedCollectionsAdapter((topicCluster, root, cover, title, titleColor, backgroundColor) -> { + final NavController navController = NavHostFragment.findNavController(this); + if (isSaving) { + setNavControllerResult(navController, topicCluster.getCollectionId()); + navController.navigateUp(); + } else { + try { + final FragmentNavigator.Extras.Builder builder = new FragmentNavigator.Extras.Builder() + .addSharedElement(cover, "collection-" + topicCluster.getCollectionId()); + final NavDirections action = SavedCollectionsFragmentDirections + .actionToCollectionPosts(topicCluster, titleColor, backgroundColor); + navController.navigate(action, builder.build()); + } catch (Exception e) { + Log.e(TAG, "setupTopics: ", e); + } + } + }); + binding.topicsRecyclerView.setAdapter(adapter); + } + + private void setupObservers() { + savedCollectionsViewModel.getList().observe(getViewLifecycleOwner(), list -> { + if (adapter == null) return; + adapter.submitList(list); + }); + } + + private void fetchTopics(final String maxId) { + binding.swipeRefreshLayout.setRefreshing(true); + profileService.fetchCollections(maxId, new ServiceCallback() { + @Override + public void onSuccess(final CollectionsListResponse result) { + if (result == null) return; + savedCollectionsViewModel.getList().postValue(result.getItems()); + binding.swipeRefreshLayout.setRefreshing(false); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure", t); + binding.swipeRefreshLayout.setRefreshing(false); + } + }); + } + + private void setNavControllerResult(@NonNull final NavController navController, final String result) { + final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); + if (navBackStackEntry == null) return; + final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); + savedStateHandle.set("collection", result); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java new file mode 100644 index 0000000..a847763 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java @@ -0,0 +1,352 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.common.collect.ImmutableList; + +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.asyncs.SavedPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentSavedBinding; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.fragments.main.ProfileFragmentDirections; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.PostItemType; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public final class SavedViewerFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = SavedViewerFragment.class.getSimpleName(); + + private FragmentSavedBinding binding; + private String username; + private long profileId; + private ActionMode actionMode; + private SwipeRefreshLayout root; + private AppCompatActivity fragmentActivity; + private boolean isLoggedIn, shouldRefresh = true; + private PostItemType type; + private Set selectedFeedModels; + private PostsLayoutPreferences layoutPreferences; + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.posts.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.multi_select_download_menu, + new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.posts.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (SavedViewerFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + DownloadUtils.download(context, ImmutableList.copyOf(SavedViewerFragment.this.selectedFeedModels)); + binding.posts.endSelection(); + } + return false; + } + }); + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel) { + openPostDialog(feedModel, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + try { + final NavDirections commentsAction = ProfileFragmentDirections.actionToComments( + feedModel.getCode(), + feedModel.getPk(), + user.getPk() + ); + NavHostFragment.findNavController(SavedViewerFragment.this).navigate(commentsAction); + } catch (Exception e) { + Log.e(TAG, "onCommentsClick: ", e); + } + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { + final Context context = getContext(); + if (context == null) return; + DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); + } + + @Override + public void onHashtagClick(final String hashtag) { + try { + final NavDirections action = ProfileFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(SavedViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onLocationClick(final Media feedModel) { + final Location location = feedModel.getLocation(); + if (location == null) return; + try { + final NavDirections action = ProfileFragmentDirections.actionToLocation(location.getPk()); + NavHostFragment.findNavController(SavedViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onLocationClick: ", e); + } + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel) { + navigateToProfile("@" + feedModel.getUser().getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(final Media feedModel, final int position) { + try { + final NavDirections action = SavedViewerFragmentDirections.actionToPost(feedModel, position); + NavHostFragment.findNavController(SavedViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + } + }; + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + SavedViewerFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (AppCompatActivity) getActivity(); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + final String cookie = settingsHelper.getString(Constants.COOKIE); + isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentSavedBinding.inflate(getLayoutInflater(), container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.saved_viewer_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onResume() { + super.onResume(); + setTitle(); + } + + @Override + public void onRefresh() { + binding.posts.refresh(); + } + + private void init() { + final Bundle arguments = getArguments(); + if (arguments == null) return; + final SavedViewerFragmentArgs fragmentArgs = SavedViewerFragmentArgs.fromBundle(arguments); + username = fragmentArgs.getUsername(); + profileId = fragmentArgs.getProfileId(); + type = fragmentArgs.getType(); + layoutPreferences = Utils.getPostsLayoutPreferences(getPostsLayoutPreferenceKey()); + setupPosts(); + } + + private void setupPosts() { + binding.posts.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new SavedPostFetchService(profileId, type, isLoggedIn, null)) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + binding.swipeRefreshLayout.setRefreshing(true); + } + + @NonNull + private String getPostsLayoutPreferenceKey() { + switch (type) { + case LIKED: + return Constants.PREF_LIKED_POSTS_LAYOUT; + case TAGGED: + return Constants.PREF_TAGGED_POSTS_LAYOUT; + case SAVED: + default: + return Constants.PREF_SAVED_POSTS_LAYOUT; + } + } + + private void setTitle() { + final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + if (actionBar == null) return; + final int titleRes; + switch (type) { + case LIKED: + titleRes = R.string.liked; + break; + case TAGGED: + titleRes = R.string.tagged; + break; + default: + case SAVED: + titleRes = R.string.saved; + break; + } + actionBar.setTitle(titleRes); + actionBar.setSubtitle(username); + } + + private void updateSwipeRefreshState() { + AppExecutors.INSTANCE.getMainThread().execute(() -> + binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) + ); + } + + private void navigateToProfile(final String username) { + try { + final NavDirections action = SavedViewerFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "navigateToProfile: ", e); + } + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + getPostsLayoutPreferenceKey(), + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); + }); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/StoryListViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/StoryListViewerFragment.java new file mode 100644 index 0000000..92dd23e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/StoryListViewerFragment.java @@ -0,0 +1,286 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SearchView; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.common.collect.Iterables; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.FeedStoriesListAdapter; +import awais.instagrabber.adapters.FeedStoriesListAdapter.OnFeedStoryClickListener; +import awais.instagrabber.adapters.HighlightStoriesListAdapter; +import awais.instagrabber.adapters.HighlightStoriesListAdapter.OnHighlightStoryClickListener; +import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; +import awais.instagrabber.databinding.FragmentStoryListViewerBinding; +import awais.instagrabber.repositories.requests.StoryViewerOptions; +import awais.instagrabber.repositories.responses.stories.ArchiveResponse; +import awais.instagrabber.repositories.responses.stories.Story; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.viewmodels.ArchivesViewModel; +import awais.instagrabber.viewmodels.FeedStoriesViewModel; +import awais.instagrabber.webservices.ServiceCallback; +import awais.instagrabber.webservices.StoriesRepository; +import kotlinx.coroutines.Dispatchers; + +public final class StoryListViewerFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "StoryListViewerFragment"; + + private AppCompatActivity fragmentActivity; + private FragmentStoryListViewerBinding binding; + private SwipeRefreshLayout root; + private boolean shouldRefresh = true; + private boolean firstRefresh = true; + private FeedStoriesViewModel feedStoriesViewModel; + private ArchivesViewModel archivesViewModel; + private StoriesRepository storiesRepository; + private Context context; + private String type; + private String endCursor = null; + private FeedStoriesListAdapter adapter; + + private final OnFeedStoryClickListener clickListener = new OnFeedStoryClickListener() { + @Override + public void onFeedStoryClick(final Story model) { + if (model == null) return; + final List feedStoryModels = feedStoriesViewModel.getList().getValue(); + if (feedStoryModels == null) return; + final int position = Iterables.indexOf(feedStoryModels, feedStoryModel -> feedStoryModel != null + && Objects.equals(feedStoryModel.getId(), model.getId())); + try { + final NavDirections action = StoryListViewerFragmentDirections.actionToStory(StoryViewerOptions.forFeedStoryPosition(position)); + NavHostFragment.findNavController(StoryListViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onFeedStoryClick: ", e); + } + } + + @Override + public void onProfileClick(final String username) { + openProfile(username); + } + }; + + private final OnHighlightStoryClickListener archiveClickListener = new OnHighlightStoryClickListener() { + @Override + public void onHighlightClick(final Story model, final int position) { + if (model == null) return; + try { + final NavDirections action = StoryListViewerFragmentDirections.actionToStory(StoryViewerOptions.forStoryArchive(position)); + NavHostFragment.findNavController(StoryListViewerFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHighlightClick: ", e); + } + } + + @Override + public void onProfileClick(final String username) { + openProfile(username); + } + }; + + private final ServiceCallback cb = new ServiceCallback() { + @Override + public void onSuccess(final ArchiveResponse result) { + binding.swipeRefreshLayout.setRefreshing(false); + if (result == null) { + try { + final Context context = getContext(); + Toast.makeText(context, R.string.empty_list, Toast.LENGTH_SHORT).show(); + } catch (Exception ignored) {} + } else { + endCursor = result.getMaxId(); + final List models = archivesViewModel.getList().getValue(); + final List modelsCopy = models == null ? new ArrayList<>() : new ArrayList<>(models); + modelsCopy.addAll(result.getItems()); + archivesViewModel.getList().postValue(modelsCopy); + } + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error", t); + try { + final Context context = getContext(); + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } catch (Exception ignored) {} + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (AppCompatActivity) requireActivity(); + context = getContext(); + if (context == null) return; + final Bundle args = getArguments(); + if (args == null) return; + final StoryListViewerFragmentArgs fragmentArgs = StoryListViewerFragmentArgs.fromBundle(args); + type = fragmentArgs.getType(); + setHasOptionsMenu(type.equals("feed")); + storiesRepository = StoriesRepository.Companion.getInstance(); + } + + @NonNull + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentStoryListViewerBinding.inflate(getLayoutInflater()); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, final MenuInflater inflater) { + inflater.inflate(R.menu.search, menu); + final MenuItem menuSearch = menu.findItem(R.id.action_search); + final SearchView searchView = (SearchView) menuSearch.getActionView(); + searchView.setQueryHint(getResources().getString(R.string.action_search)); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + + @Override + public boolean onQueryTextSubmit(final String query) { + return false; + } + + @Override + public boolean onQueryTextChange(final String query) { + if (adapter != null) { + adapter.getFilter().filter(query); + } + return true; + } + }); + } + + @Override + public void onResume() { + super.onResume(); + final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + if (actionBar != null) actionBar.setTitle(type.equals("feed") ? R.string.feed_stories : R.string.action_archive); + } + + @Override + public void onDestroy() { + if (archivesViewModel != null) archivesViewModel.getList().postValue(null); + super.onDestroy(); + } + + private void init() { + final Context context = getContext(); + binding.swipeRefreshLayout.setOnRefreshListener(this); + final LinearLayoutManager layoutManager = new LinearLayoutManager(context); + final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + if (type.equals("feed")) { + if (actionBar != null) actionBar.setTitle(R.string.feed_stories); + feedStoriesViewModel = new ViewModelProvider(fragmentActivity).get(FeedStoriesViewModel.class); + adapter = new FeedStoriesListAdapter(clickListener); + binding.rvStories.setLayoutManager(layoutManager); + binding.rvStories.setAdapter(adapter); + feedStoriesViewModel.getList().observe(getViewLifecycleOwner(), list -> { + if (list == null) { + adapter.submitList(Collections.emptyList()); + return; + } + adapter.submitList(list); + }); + } else { + if (actionBar != null) actionBar.setTitle(R.string.action_archive); + final RecyclerLazyLoader lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> { + if (!TextUtils.isEmpty(endCursor)) onRefresh(); + endCursor = null; + }); + binding.rvStories.addOnScrollListener(lazyLoader); + archivesViewModel = new ViewModelProvider(fragmentActivity).get(ArchivesViewModel.class); + final HighlightStoriesListAdapter adapter = new HighlightStoriesListAdapter(archiveClickListener); + binding.rvStories.setLayoutManager(layoutManager); + binding.rvStories.setAdapter(adapter); + archivesViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); + } + onRefresh(); + } + + @Override + public void onRefresh() { + binding.swipeRefreshLayout.setRefreshing(true); + if (type.equals("feed") && firstRefresh) { + binding.swipeRefreshLayout.setRefreshing(false); + final List value = feedStoriesViewModel.getList().getValue(); + if (value != null) { + adapter.submitList(value); + } + firstRefresh = false; + } else if (type.equals("feed")) { + storiesRepository.getFeedStories( + CoroutineUtilsKt.getContinuation((feedStoryModels, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "failed", throwable); + Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); + return; + } + //noinspection unchecked + feedStoriesViewModel.getList().postValue((List) feedStoryModels); + //noinspection unchecked + adapter.submitList((List) feedStoryModels); + binding.swipeRefreshLayout.setRefreshing(false); + }), Dispatchers.getIO()) + ); + } else if (type.equals("archive")) { + storiesRepository.fetchArchive( + endCursor, + CoroutineUtilsKt.getContinuation((archiveFetchResponse, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + cb.onFailure(throwable); + return; + } + cb.onSuccess(archiveFetchResponse); + }), Dispatchers.getIO()) + ); + } + } + + private void openProfile(final String username) { + try { + final NavDirections action = StoryListViewerFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openProfile: ", e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.kt b/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.kt new file mode 100644 index 0000000..771d17b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.kt @@ -0,0 +1,913 @@ +package awais.instagrabber.fragments + +import android.annotation.SuppressLint +import android.content.DialogInterface.OnClickListener +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.util.Log +import android.view.* +import android.view.GestureDetector.SimpleOnGestureListener +import android.widget.* +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.TooltipCompat +import androidx.core.view.GestureDetectorCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.LinearLayoutManager +import awais.instagrabber.BuildConfig +import awais.instagrabber.R +import awais.instagrabber.adapters.StoriesAdapter +import awais.instagrabber.customviews.helpers.SwipeGestureListener +import awais.instagrabber.databinding.FragmentStoryViewerBinding +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.interfaces.SwipeEvent +import awais.instagrabber.models.Resource +import awais.instagrabber.models.enums.FavoriteType +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.models.enums.StoryPaginationType +import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.repositories.responses.stories.* +import awais.instagrabber.utils.DownloadUtils.download +import awais.instagrabber.utils.ResponseBodyUtils +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.viewmodels.ArchivesViewModel +import awais.instagrabber.viewmodels.FeedStoriesViewModel +import awais.instagrabber.viewmodels.StoryFragmentViewModel +import awais.instagrabber.webservices.MediaRepository +import awais.instagrabber.webservices.StoriesRepository +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.controller.BaseControllerListener +import com.facebook.drawee.interfaces.DraweeController +import com.facebook.imagepipeline.image.ImageInfo +import com.facebook.imagepipeline.request.ImageRequestBuilder +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.source.* +import com.google.android.exoplayer2.source.dash.DashMediaSource +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.material.textfield.TextInputEditText +import java.io.IOException +import java.text.NumberFormat +import java.util.* + + +class StoryViewerFragment : Fragment() { + private val TAG = "StoryViewerFragment" + + private var root: View? = null + private var currentStoryUsername: String? = null + private var storiesAdapter: StoriesAdapter? = null + private var swipeEvent: SwipeEvent? = null + private var gestureDetector: GestureDetectorCompat? = null + private val storiesRepository: StoriesRepository? = null + private val mediaRepository: MediaRepository? = null + private var menuProfile: MenuItem? = null + private var profileVisible: Boolean = false + private var player: SimpleExoPlayer? = null + + private var shouldRefresh = true + private var currentFeedStoryIndex = 0 + private var sliderValue = 0.0 + private var options: StoryViewerOptions? = null + private var listViewModel: ViewModel? = null + private var backStackSavedStateResultLiveData: MutableLiveData? = null + private lateinit var fragmentActivity: AppCompatActivity + private lateinit var storiesViewModel: StoryFragmentViewModel + private lateinit var binding: FragmentStoryViewerBinding + + @Suppress("UNCHECKED_CAST") + private val backStackSavedStateObserver = Observer { result -> + if (result == null) return@Observer + if ((result is RankedRecipient)) { + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + storiesViewModel.shareDm(result) + } else if ((result is Set<*>)) { + try { + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + storiesViewModel.shareDm(result as Set) + } catch (e: Exception) { + Log.e(TAG, "share: ", e) + } + } + // clear result + backStackSavedStateResultLiveData?.postValue(null) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + fragmentActivity = requireActivity() as AppCompatActivity + storiesViewModel = ViewModelProvider(this).get(StoryFragmentViewModel::class.java) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + if (root != null) { + shouldRefresh = false + return root + } + binding = FragmentStoryViewerBinding.inflate(inflater, container, false) + root = binding.root + return root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (!shouldRefresh) return + init() + shouldRefresh = false + } + + override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.story_menu, menu) + menuProfile = menu.findItem(R.id.action_profile) + menuProfile!!.isVisible = profileVisible + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val itemId = item.itemId + if (itemId == R.id.action_profile) { + val username = storiesViewModel.getCurrentStory().value?.user?.username + openProfile(Pair(username, FavoriteType.USER)) + return true + } + return false + } + + override fun onPause() { + super.onPause() + player?.pause() ?: return + } + + override fun onResume() { + super.onResume() + setHasOptionsMenu(true) + try { + val backStackEntry = NavHostFragment.findNavController(this).currentBackStackEntry + if (backStackEntry != null) { + backStackSavedStateResultLiveData = backStackEntry.savedStateHandle.getLiveData("result") + backStackSavedStateResultLiveData?.observe(viewLifecycleOwner, backStackSavedStateObserver) + } + } catch (e: Exception) { + Log.e(TAG, "onResume: ", e) + } + val actionBar = fragmentActivity.supportActionBar ?: return + actionBar.title = storiesViewModel.getTitle().value + actionBar.subtitle = storiesViewModel.getDate().value + } + + override fun onDestroy() { + releasePlayer() + val actionBar = fragmentActivity.supportActionBar + actionBar?.subtitle = null + super.onDestroy() + } + + private fun init() { + val args = arguments ?: return + val fragmentArgs = StoryViewerFragmentArgs.fromBundle(args) + options = fragmentArgs.options + currentFeedStoryIndex = options!!.currentFeedStoryIndex + val type = options!!.type + if (currentFeedStoryIndex >= 0) { + listViewModel = when (type) { + StoryViewerOptions.Type.STORY_ARCHIVE -> + ViewModelProvider(fragmentActivity).get(ArchivesViewModel::class.java) + StoryViewerOptions.Type.FEED_STORY_POSITION -> + ViewModelProvider(fragmentActivity).get(FeedStoriesViewModel::class.java) + else -> null + } + } + setupButtons() + setupStories() + } + + private fun setupStories() { + setupListeners() + val context = context ?: return + binding.storiesList.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + storiesAdapter = StoriesAdapter { _, position -> + storiesViewModel.setMedia(position) + } + binding.storiesList.adapter = storiesAdapter + storiesViewModel.getCurrentStory().observe(fragmentActivity, { + if (it?.items != null && it.items.size > 1) { + val storyMedias = it.items.toMutableList() + val newItem = storyMedias.get(0) + newItem.isCurrentSlide = true + storyMedias.set(0, newItem) + storiesAdapter!!.submitList(storyMedias) + storiesViewModel.setMedia(0) + binding.listToggle.isEnabled = true + binding.storiesList.visibility = if (Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_STORY_SHOW_LIST)) View.VISIBLE + else View.GONE + } + else { + if (it?.items != null) storiesViewModel.setMedia(0) + binding.listToggle.isEnabled = false + binding.storiesList.visibility = View.GONE + } + }) + storiesViewModel.getDate().observe(fragmentActivity, { + val actionBar = fragmentActivity.supportActionBar + if (actionBar != null && it != null) actionBar.subtitle = it + }) + storiesViewModel.getTitle().observe(fragmentActivity, { + val actionBar = fragmentActivity.supportActionBar + if (actionBar != null && it != null) actionBar.title = it + }) + storiesViewModel.getCurrentMedia().observe(fragmentActivity, { refreshStory(it) }) + storiesViewModel.getCurrentIndex().observe(fragmentActivity, { + storiesAdapter!!.paginate(it) + }) + storiesViewModel.getOptions().observe(fragmentActivity, { + binding.stickers.isEnabled = it.first.size > 0 + }) + } + + private fun setupButtons() { + binding.btnDownload.setOnClickListener({ _ -> downloadStory() }) + binding.btnForward.setOnClickListener({ _ -> storiesViewModel.skip(false) }) + binding.btnBackward.setOnClickListener({ _ -> storiesViewModel.skip(true) }) + binding.btnShare.setOnClickListener({ _ -> shareStoryViaDm() }) + binding.btnReply.setOnClickListener({ _ -> createReplyDialog(null) }) + binding.stickers.setOnClickListener({ _ -> showStickerMenu() }) + binding.listToggle.setOnClickListener({ _ -> + binding.storiesList.visibility = if (binding.storiesList.visibility == View.GONE) View.VISIBLE + else View.GONE + }) + + TooltipCompat.setTooltipText(binding.btnDownload, getString(R.string.action_download)) + TooltipCompat.setTooltipText(binding.btnShare, getString(R.string.share)) + TooltipCompat.setTooltipText(binding.btnReply, getString(R.string.reply_story)) + TooltipCompat.setTooltipText(binding.stickers, getString(R.string.story_stickers)) + TooltipCompat.setTooltipText(binding.listToggle, getString(R.string.story_list)) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupListeners() { + if (currentFeedStoryIndex >= 0) { + val type = options!!.type + when (type) { + StoryViewerOptions.Type.HIGHLIGHT -> { + storiesViewModel.fetchHighlights(options!!.id) + storiesViewModel.highlights.observe(fragmentActivity) { + setupMultipage(it) + } + } + StoryViewerOptions.Type.FEED_STORY_POSITION -> { + val feedStoriesViewModel = listViewModel as FeedStoriesViewModel? + setupMultipage(feedStoriesViewModel!!.list.value) + } + StoryViewerOptions.Type.STORY_ARCHIVE -> { + val archivesViewModel = listViewModel as ArchivesViewModel? + setupMultipage(archivesViewModel!!.list.value) + } + StoryViewerOptions.Type.USER -> { + resetView() + } + } + } + + val context = context ?: return + swipeEvent = SwipeEvent { isRightSwipe: Boolean -> + storiesViewModel.paginate(isRightSwipe) + } + gestureDetector = GestureDetectorCompat(context, SwipeGestureListener(swipeEvent)) + binding.playerView.setOnTouchListener { _, event -> gestureDetector!!.onTouchEvent(event) } + val simpleOnGestureListener: SimpleOnGestureListener = object : SimpleOnGestureListener() { + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + val diffX = e2.x - e1.x + try { + if (Math.abs(diffX) > Math.abs(e2.y - e1.y) && Math.abs(diffX) > SwipeGestureListener.SWIPE_THRESHOLD && Math.abs( + velocityX + ) > SwipeGestureListener.SWIPE_VELOCITY_THRESHOLD + ) { + storiesViewModel.paginate(diffX > 0) + return true + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.e(TAG, "Error", e) + } + return false + } + } + binding.imageViewer.setTapListener(simpleOnGestureListener) + } + + private fun setupMultipage(models: List?) { + if (models == null) return + storiesViewModel.getPagination().observe(fragmentActivity, { + when (it) { + StoryPaginationType.FORWARD -> { + if (currentFeedStoryIndex == models.size - 1) + Toast.makeText( + context, + R.string.no_more_stories, + Toast.LENGTH_SHORT + ).show() + else paginateStories(false, currentFeedStoryIndex == models.size - 2) + } + StoryPaginationType.BACKWARD -> { + if (currentFeedStoryIndex == 0) + Toast.makeText( + context, + R.string.no_more_stories, + Toast.LENGTH_SHORT + ).show() + else paginateStories(true, false) + } + StoryPaginationType.ERROR -> { + Toast.makeText( + context, + R.string.downloader_unknown_error, + Toast.LENGTH_SHORT + ).show() + } + } + }) + if (!models.isEmpty()) { + binding.btnBackward.isEnabled = currentFeedStoryIndex != 0 + binding.btnForward.isEnabled = currentFeedStoryIndex != models.size - 1 + resetView() + } + } + + private fun resetView() { + val context = context ?: return + if (menuProfile != null) menuProfile!!.isVisible = false + binding.imageViewer.controller = null + releasePlayer() + val type = options!!.type + var fetchOptions: StoryViewerOptions? = null + when (type) { + StoryViewerOptions.Type.HIGHLIGHT -> { + val models = storiesViewModel.highlights.value + if (models == null || models.isEmpty() || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) { + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show() + return + } + fetchOptions = StoryViewerOptions.forHighlight(0L, models[currentFeedStoryIndex].id) + } + StoryViewerOptions.Type.FEED_STORY_POSITION -> { + val feedStoriesViewModel = listViewModel as FeedStoriesViewModel? + val models = feedStoriesViewModel!!.list.value + if (models == null || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) return + val userStory = models[currentFeedStoryIndex] + currentStoryUsername = userStory.user!!.username + fetchOptions = StoryViewerOptions.forUser(userStory.user.pk, currentStoryUsername) + val live = userStory.broadcast + if (live != null) { + storiesViewModel.setStory(userStory) + refreshLive(live) + return + } + } + StoryViewerOptions.Type.STORY_ARCHIVE -> { + val archivesViewModel = listViewModel as ArchivesViewModel? + val models = archivesViewModel!!.list.value + if (models == null || models.isEmpty() || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) { + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT) + .show() + return + } + val (id, _, _, _, _, _, _, _, _, title) = models[currentFeedStoryIndex] + currentStoryUsername = title + fetchOptions = StoryViewerOptions.forStoryArchive(id) + } + StoryViewerOptions.Type.USER -> { + currentStoryUsername = options!!.name + fetchOptions = StoryViewerOptions.forUser(options!!.id, currentStoryUsername) + } + } + if (type == StoryViewerOptions.Type.STORY) { + storiesViewModel.fetchSingleMedia(options!!.id) + return + } + storiesViewModel.fetchStory(fetchOptions).observe(viewLifecycleOwner, { + if (it.status == Resource.Status.ERROR) { + Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT).show() + } + }) + } + + @Synchronized + private fun refreshLive(live: Broadcast) { + binding.btnDownload.isEnabled = false + binding.stickers.isEnabled = false + binding.listToggle.isEnabled = false + binding.btnShare.isEnabled = false + binding.btnReply.isEnabled = false + releasePlayer() + setupLive(live.dashPlaybackUrl ?: live.dashAbrPlaybackUrl ?: return) + } + + @Synchronized + private fun refreshStory(currentStory: StoryMedia) { + val itemType = currentStory.type + val url = if (itemType === MediaItemType.MEDIA_TYPE_IMAGE) ResponseBodyUtils.getImageUrl(currentStory) + else ResponseBodyUtils.getVideoUrl(currentStory) + + releasePlayer() + + profileVisible = currentStory.user?.username != null + if (menuProfile != null) menuProfile!!.isVisible = profileVisible + + binding.btnDownload.isEnabled = false + binding.btnShare.isEnabled = currentStory.canReshare + binding.btnReply.isEnabled = currentStory.canReply + if (itemType === MediaItemType.MEDIA_TYPE_VIDEO) setupVideo(url) else setupImage(url) + + if (options!!.type == StoryViewerOptions.Type.FEED_STORY_POSITION + && Utils.settingsHelper.getBoolean(PreferenceKeys.MARK_AS_SEEN)) { + val feedStoriesViewModel = listViewModel as FeedStoriesViewModel? + storiesViewModel.markAsSeen(currentStory).observe(viewLifecycleOwner) { m -> + if (m.status == Resource.Status.SUCCESS && m.data != null) { + val liveModels: MutableLiveData> = feedStoriesViewModel!!.list + val models = liveModels.value + val modelsCopy: MutableList = models!!.toMutableList() + modelsCopy.set(currentFeedStoryIndex, m.data) + liveModels.postValue(modelsCopy) + } + } + } + } + + private fun downloadStory() { + val context = context ?: return + val currentStory = storiesViewModel.getMedia().value + if (currentStory == null) { + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show() + return + } + download(context, currentStory) + } + + private fun setupImage(url: String) { + binding.progressView.visibility = View.VISIBLE + binding.playerView.visibility = View.GONE + binding.imageViewer.visibility = View.VISIBLE + val requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(url)) + .setLocalThumbnailPreviewsEnabled(true) + .setProgressiveRenderingEnabled(true) + .build() + val controller: DraweeController = Fresco.newDraweeControllerBuilder() + .setImageRequest(requestBuilder) + .setOldController(binding.imageViewer.controller) + .setControllerListener(object : BaseControllerListener() { + override fun onFailure(id: String, throwable: Throwable) { + binding.btnDownload.isEnabled = false + binding.progressView.visibility = View.GONE + } + + override fun onFinalImageSet( + id: String, + imageInfo: ImageInfo?, + animatable: Animatable? + ) { + binding.btnDownload.isEnabled = true + binding.progressView.visibility = View.GONE + } + }) + .build() + binding.imageViewer.controller = controller + } + + private fun setupVideo(url: String) { + binding.playerView.visibility = View.VISIBLE + binding.progressView.visibility = View.GONE + binding.imageViewer.visibility = View.GONE + binding.imageViewer.controller = null + val context = context ?: return + player = SimpleExoPlayer.Builder(context).build() + binding.playerView.player = player + player!!.playWhenReady = + Utils.settingsHelper.getBoolean(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES) + val uri = Uri.parse(url) + val mediaItem = MediaItem.fromUri(uri) + val mediaSource = + ProgressiveMediaSource.Factory(DefaultDataSourceFactory(context, "instagram")) + .createMediaSource(mediaItem) + mediaSource.addEventListener(Handler(), object : MediaSourceEventListener { + override fun onLoadCompleted( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData + ) { + binding.btnDownload.isEnabled = true + binding.progressView.visibility = View.GONE + } + + override fun onLoadStarted( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData + ) { + binding.btnDownload.isEnabled = true + binding.progressView.visibility = View.VISIBLE + } + + override fun onLoadCanceled( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData + ) { + binding.progressView.visibility = View.GONE + } + + override fun onLoadError( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + error: IOException, + wasCanceled: Boolean + ) { + binding.btnDownload.isEnabled = false + binding.progressView.visibility = View.GONE + } + }) + player!!.setMediaSource(mediaSource) + player!!.prepare() + binding.playerView.setOnClickListener { _ -> + if (player != null) { + if (player!!.playbackState == Player.STATE_ENDED) player!!.seekTo(0) + player!!.playWhenReady = + player!!.playbackState == Player.STATE_ENDED || !player!!.isPlaying + } + } + } + + private fun setupLive(url: String) { + binding.playerView.visibility = View.VISIBLE + binding.progressView.visibility = View.GONE + binding.imageViewer.visibility = View.GONE + binding.imageViewer.controller = null + val context = context ?: return + player = SimpleExoPlayer.Builder(context).build() + binding.playerView.player = player + player!!.playWhenReady = + Utils.settingsHelper.getBoolean(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES) + val uri = Uri.parse(url) + val mediaItem = MediaItem.fromUri(uri) + val mediaSource = DashMediaSource.Factory(DefaultDataSourceFactory(context, "instagram")) + .createMediaSource(mediaItem) + mediaSource.addEventListener(Handler(), object : MediaSourceEventListener { + override fun onLoadCompleted( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData + ) { + binding.progressView.visibility = View.GONE + } + + override fun onLoadStarted( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData + ) { + binding.progressView.visibility = View.VISIBLE + } + + override fun onLoadCanceled( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData + ) { + binding.progressView.visibility = View.GONE + } + + override fun onLoadError( + windowIndex: Int, + mediaPeriodId: MediaSource.MediaPeriodId?, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + error: IOException, + wasCanceled: Boolean + ) { + binding.progressView.visibility = View.GONE + } + }) + player!!.setMediaSource(mediaSource) + player!!.prepare() + binding.playerView.setOnClickListener { _ -> + if (player != null) { + if (player!!.playbackState == Player.STATE_ENDED) player!!.seekTo(0) + player!!.playWhenReady = + player!!.playbackState == Player.STATE_ENDED || !player!!.isPlaying + } + } + } + + private fun openProfile(data: Pair) { + val navController: NavController = NavHostFragment.findNavController(this) + val bundle = Bundle() + if (data.first == null) { + // toast + return + } + val actionBar = fragmentActivity.supportActionBar + if (actionBar != null) { + actionBar.title = null + actionBar.subtitle = null + } + val action = when (data.second) { + FavoriteType.USER -> { + StoryViewerFragmentDirections.actionToProfile().apply { this.username = data.first!! } + } + FavoriteType.HASHTAG -> { + StoryViewerFragmentDirections.actionToHashtag(data.first!!) + } + FavoriteType.LOCATION -> { + StoryViewerFragmentDirections.actionToLocation(data.first!!.toLong()) + } + else -> null + } + navController.navigate(action!!) + } + + private fun releasePlayer() { + if (player == null) return + try { + player!!.stop(true) + } catch (ignored: Exception) { + } + try { + player!!.release() + } catch (ignored: Exception) { + } + player = null + } + + private fun paginateStories( + backward: Boolean, + last: Boolean + ) { + binding.btnBackward.isEnabled = currentFeedStoryIndex != 1 || !backward + binding.btnForward.isEnabled = !last + currentFeedStoryIndex = if (backward) currentFeedStoryIndex - 1 else currentFeedStoryIndex + 1 + resetView() + } + + private fun createChoiceDialog( + title: String?, + tallies: List, + onClickListener: OnClickListener, + viewerVote: Int?, + correctAnswer: Int? + ) { + val context = context ?: return + val choices = tallies.map { + (if (viewerVote == tallies.indexOf(it)) "√ " else "") + + (if (correctAnswer == tallies.indexOf(it)) "*** " else "") + + it.text + " (" + it.count + ")" } + val builder = AlertDialog.Builder(context) + if (title != null) builder.setTitle(title) + if (viewerVote != null) builder.setMessage(R.string.story_quizzed) + builder.setPositiveButton(if (viewerVote == null) R.string.cancel else R.string.ok, null) + val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, choices.toTypedArray()) + builder.setAdapter(adapter, onClickListener) + builder.show() + } + + private fun createMentionDialog() { + val context = context ?: return + val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, storiesViewModel.getMentionTexts()) + val builder = AlertDialog.Builder(context) + .setPositiveButton(R.string.ok, null) + .setAdapter(adapter, { _, w -> + val data = storiesViewModel.getMention(w) + if (data != null) openProfile(Pair(data.second, data.third)) + }) + builder.show() + } + + private fun createSliderDialog() { + val slider = storiesViewModel.getSlider().value ?: return + val context = context ?: return + val percentage: NumberFormat = NumberFormat.getPercentInstance() + percentage.maximumFractionDigits = 2 + val sliderView = LinearLayout(context) + sliderView.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + sliderView.orientation = LinearLayout.VERTICAL + val tv = TextView(context) + tv.gravity = Gravity.CENTER_HORIZONTAL + val input = SeekBar(context) + val avg: Double = slider.sliderVoteAverage ?: 0.5 + input.progress = (avg * 100).toInt() + var onClickListener: OnClickListener? = null + + if (slider.viewerVote == null && slider.viewerCanVote == true) { + input.isEnabled = true + input.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + sliderValue = progress / 100.0 + tv.text = percentage.format(sliderValue) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + onClickListener = OnClickListener { _, _ -> storiesViewModel.answerSlider(sliderValue) } + } + else { + input.isEnabled = false + tv.text = getString(R.string.slider_answer, percentage.format(slider.viewerVote)) + } + sliderView.addView(input) + sliderView.addView(tv) + val builder = AlertDialog.Builder(context) + .setTitle(if (slider.question.isNullOrEmpty()) slider.emoji else slider.question) + .setMessage( + resources.getQuantityString(R.plurals.slider_info, + slider.sliderVoteCount ?: 0, + slider.sliderVoteCount ?: 0, + percentage.format(avg))) + .setView(sliderView) + .setPositiveButton(R.string.ok, onClickListener) + + builder.show() + } + + private fun createReplyDialog(question: String?) { + val context = context ?: return + val input = TextInputEditText(context) + input.setHint(R.string.reply_hint) + val builder = AlertDialog.Builder(context) + .setTitle(question ?: context.getString(R.string.reply_story)) + .setView(input) + val onClickListener = OnClickListener{ _, _ -> + val result = + if (question != null) storiesViewModel.answerQuestion(input.text.toString()) + else storiesViewModel.reply(input.text.toString()) + if (result == null) { + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT) + .show() + } + else result.observe(viewLifecycleOwner, { + when (it.status) { + Resource.Status.SUCCESS -> { + Toast.makeText(context, R.string.answered_story, Toast.LENGTH_SHORT) + .show() + } + Resource.Status.ERROR -> { + Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT) + .show() + } + Resource.Status.LOADING -> { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + } + }) + } + builder.setPositiveButton(R.string.confirm, onClickListener) + builder.show() + } + + private fun shareStoryViaDm() { + val story = storiesViewModel.getCurrentStory().value ?: return + val context = context + if (story.user?.isPrivate == true && context != null) { + Toast.makeText(context, R.string.share_private_post, Toast.LENGTH_SHORT).show() + } + val actionBar = fragmentActivity.supportActionBar + if (actionBar != null) actionBar.subtitle = null + val actionGlobalUserSearch = StoryViewerFragmentDirections.actionToUserSearch().apply { + title = getString(R.string.share) + actionLabel = getString(R.string.send) + showGroups = true + multiple = true + searchMode = UserSearchMode.RAVEN + } + try { + val navController = NavHostFragment.findNavController(this@StoryViewerFragment) + navController.navigate(actionGlobalUserSearch) + } catch (e: Exception) { + Log.e(TAG, "shareStoryViaDm: ", e) + } + } + + private fun showStickerMenu() { + val data = storiesViewModel.getOptions().value + if (data == null) return + val themeWrapper = ContextThemeWrapper(context, R.style.popupMenuStyle) + val popupMenu = PopupMenu(themeWrapper, binding.stickers) + val menu = popupMenu.menu + data.first.map { + if (it.second != 0) menu.add(0, it.first, 0, it.second) + if (it.first == R.id.swipeUp) menu.add(0, R.id.swipeUp, 0, data.second) + if (it.first == R.id.spotify) menu.add(0, R.id.spotify, 0, data.third) + } + popupMenu.setOnMenuItemClickListener { item: MenuItem -> + val itemId = item.itemId + if (itemId == R.id.spotify) openExternalLink(storiesViewModel.getAppAttribution()) + else if (itemId == R.id.swipeUp) openExternalLink(storiesViewModel.getSwipeUp()) + else if (itemId == R.id.mentions) createMentionDialog() + else if (itemId == R.id.slider) createSliderDialog() + else if (itemId == R.id.question) { + val question = storiesViewModel.getQuestion().value + if (question != null) createReplyDialog(question.question) + } + else if (itemId == R.id.quiz) { + val quiz = storiesViewModel.getQuiz().value + if (quiz != null) createChoiceDialog( + quiz.question, + quiz.tallies, + { _, w -> storiesViewModel.answerQuiz(w) }, + quiz.viewerAnswer, + quiz.correctAnswer + ) + } + else if (itemId == R.id.poll) { + val poll = storiesViewModel.getPoll().value + if (poll != null) createChoiceDialog( + poll.question, + poll.tallies, + { _, w -> storiesViewModel.answerPoll(w) }, + poll.viewerVote, + null + ) + } + else if (itemId == R.id.viewStoryPost) { + storiesViewModel.getLinkedPost().observe(viewLifecycleOwner, { + if (it == null) Toast.makeText(context, "Error: LiveData is null", Toast.LENGTH_SHORT).show() + else when (it.status) { + Resource.Status.SUCCESS -> { + if (it.data != null) { + val actionBar = fragmentActivity.supportActionBar + if (actionBar != null) { + actionBar.title = null + actionBar.subtitle = null + } + val navController = + NavHostFragment.findNavController(this@StoryViewerFragment) + val bundle = Bundle() + bundle.putSerializable(PostViewV2Fragment.ARG_MEDIA, it.data) + try { + navController.navigate(StoryViewerFragmentDirections.actionToPost(it.data, 0)) + } catch (e: Exception) { + Log.e(TAG, "openPostDialog: ", e) + } + } + } + Resource.Status.ERROR -> { + Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT) + .show() + } + Resource.Status.LOADING -> { + Toast.makeText(context, R.string.opening_post, Toast.LENGTH_SHORT) + .show() + } + } + }) + } + false + } + popupMenu.show() + } + + private fun openExternalLink(url: String?) { + val context = context ?: return + if (url == null) return + AlertDialog.Builder(context) + .setTitle(R.string.swipe_up_confirmation) + .setMessage(url).setPositiveButton(R.string.yes, { _, _ -> Utils.openURL(context, url) }) + .setNegativeButton(R.string.no, null) + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/TopicPostsFragment.java b/app/src/main/java/awais/instagrabber/fragments/TopicPostsFragment.java new file mode 100644 index 0000000..cd3ba77 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/TopicPostsFragment.java @@ -0,0 +1,393 @@ +package awais.instagrabber.fragments; + +import android.animation.ArgbEvaluator; +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.graphics.ColorUtils; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.transition.ChangeBounds; +import androidx.transition.TransitionInflater; +import androidx.transition.TransitionSet; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.imagepipeline.image.ImageInfo; +import com.google.common.collect.ImmutableList; + +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.asyncs.DiscoverPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentTopicPostsBinding; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.discover.TopicCluster; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.DiscoverService; + +public class TopicPostsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = TopicPostsFragment.class.getSimpleName(); + + private MainActivity fragmentActivity; + private FragmentTopicPostsBinding binding; + private CoordinatorLayout root; + private boolean shouldRefresh = true; + private TopicCluster topicCluster; + private ActionMode actionMode; + private Set selectedFeedModels; + private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_TOPIC_POSTS_LAYOUT); + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.posts.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.posts.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, + final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (TopicPostsFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + DownloadUtils.download(context, ImmutableList.copyOf(TopicPostsFragment.this.selectedFeedModels)); + binding.posts.endSelection(); + return true; + } + return false; + } + }); + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel) { + openPostDialog(feedModel, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + final NavDirections commentsAction = TopicPostsFragmentDirections.actionToComments( + feedModel.getCode(), + feedModel.getPk(), + user.getPk() + ); + NavHostFragment.findNavController(TopicPostsFragment.this).navigate(commentsAction); + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { + final Context context = getContext(); + if (context == null) return; + DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); + } + + @Override + public void onHashtagClick(final String hashtag) { + final NavDirections action = TopicPostsFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(TopicPostsFragment.this).navigate(action); + } + + @Override + public void onLocationClick(final Media feedModel) { + final Location location = feedModel.getLocation(); + if (location == null) return; + final NavDirections action = TopicPostsFragmentDirections.actionToLocation(location.getPk()); + NavHostFragment.findNavController(TopicPostsFragment.this).navigate(action); + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel) { + navigateToProfile("@" + feedModel.getUser().getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel) { + final User user = feedModel.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(final Media feedModel, final int position) { + try { + final NavDirections action = TopicPostsFragmentDirections.actionToPost(feedModel, position); + NavHostFragment.findNavController(TopicPostsFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + } + }; + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + TopicPostsFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + final Context context = getContext(); + if (context != null) { + final TransitionSet transitionSet = new TransitionSet(); + transitionSet.addTransition(new ChangeBounds()) + .addTransition(TransitionInflater.from(context).inflateTransition(android.R.transition.move)) + .setDuration(200); + setSharedElementEnterTransition(transitionSet); + } + postponeEnterTransition(); + setHasOptionsMenu(true); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentTopicPostsBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.topic_posts_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onResume() { + super.onResume(); + fragmentActivity.setToolbar(binding.toolbar, this); + } + + @Override + public void onRefresh() { + binding.posts.refresh(); + } + + @Override + public void onStop() { + super.onStop(); + fragmentActivity.resetToolbar(this); + } + + private void init() { + if (getArguments() == null) return; + final TopicPostsFragmentArgs fragmentArgs = TopicPostsFragmentArgs.fromBundle(getArguments()); + topicCluster = fragmentArgs.getTopicCluster(); + setupToolbar(fragmentArgs.getTitleColor(), fragmentArgs.getBackgroundColor()); + setupPosts(); + } + + private void setupToolbar(final int titleColor, final int backgroundColor) { + if (topicCluster == null) { + return; + } + binding.cover.setTransitionName("cover-" + topicCluster.getId()); + fragmentActivity.setToolbar(binding.toolbar, this); + binding.collapsingToolbarLayout.setTitle(topicCluster.getTitle()); + final int collapsedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0xFF); + final int expandedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0x99); + binding.collapsingToolbarLayout.setExpandedTitleColor(expandedTitleTextColor); + binding.collapsingToolbarLayout.setCollapsedTitleTextColor(collapsedTitleTextColor); + binding.collapsingToolbarLayout.setContentScrimColor(backgroundColor); + final Drawable navigationIcon = binding.toolbar.getNavigationIcon(); + final Drawable overflowIcon = binding.toolbar.getOverflowIcon(); + if (navigationIcon != null && overflowIcon != null) { + final Drawable navDrawable = navigationIcon.mutate(); + final Drawable overflowDrawable = overflowIcon.mutate(); + navDrawable.setAlpha(0xFF); + overflowDrawable.setAlpha(0xFF); + final ArgbEvaluator argbEvaluator = new ArgbEvaluator(); + binding.appBarLayout.addOnOffsetChangedListener((appBarLayout, verticalOffset) -> { + final int totalScrollRange = appBarLayout.getTotalScrollRange(); + final float current = totalScrollRange + verticalOffset; + final float fraction = current / totalScrollRange; + final int tempColor = (int) argbEvaluator.evaluate(fraction, collapsedTitleTextColor, expandedTitleTextColor); + navDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); + overflowDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); + + }); + } + final GradientDrawable gd = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{Color.TRANSPARENT, backgroundColor}); + binding.background.setBackground(gd); + setupCover(); + } + + private void setupCover() { + final String coverUrl = ResponseBodyUtils.getImageUrl(topicCluster.getCoverMedia()); + final DraweeController controller = Fresco + .newDraweeControllerBuilder() + .setOldController(binding.cover.getController()) + .setUri(coverUrl) + .setControllerListener(new BaseControllerListener() { + + @Override + public void onFailure(final String id, final Throwable throwable) { + super.onFailure(id, throwable); + startPostponedEnterTransition(); + } + + @Override + public void onFinalImageSet(final String id, + @Nullable final ImageInfo imageInfo, + @Nullable final Animatable animatable) { + startPostponedEnterTransition(); + } + }) + .build(); + binding.cover.setController(controller); + } + + private void setupPosts() { + final DiscoverService.TopicalExploreRequest topicalExploreRequest = new DiscoverService.TopicalExploreRequest(); + topicalExploreRequest.setClusterId(topicCluster.getId()); + binding.posts.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new DiscoverPostFetchService(topicalExploreRequest)) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + binding.swipeRefreshLayout.setRefreshing(true); + } + + private void updateSwipeRefreshState() { + AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) + ); + } + + private void navigateToProfile(final String username) { + try { + final NavDirections action = TopicPostsFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "navigateToProfile: ", e); + } + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + Constants.PREF_TOPIC_POSTS_LAYOUT, + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); + }); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.kt b/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.kt new file mode 100644 index 0000000..a72be70 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.kt @@ -0,0 +1,252 @@ +package awais.instagrabber.fragments + +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.OnHierarchyChangeListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.transition.TransitionManager +import awais.instagrabber.activities.MainActivity +import awais.instagrabber.adapters.UserSearchResultsAdapter +import awais.instagrabber.customviews.helpers.TextWatcherAdapter +import awais.instagrabber.databinding.FragmentUserSearchBinding +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.trimAll +import awais.instagrabber.utils.measure +import awais.instagrabber.viewmodels.UserSearchViewModel +import com.google.android.material.chip.Chip +import com.google.android.material.snackbar.Snackbar + +class UserSearchFragment : Fragment() { + + private lateinit var binding: FragmentUserSearchBinding + + private var resultsAdapter: UserSearchResultsAdapter? = null + private var paddingOffset = 0 + private var actionLabel: String? = null + private var title: String? = null + private var multiple = false + + private val viewModel: UserSearchViewModel by viewModels() + private val windowWidth = Utils.displayMetrics.widthPixels + private val minInputWidth = Utils.convertDpToPx(50f) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentUserSearchBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + paddingOffset = with(binding) { + search.paddingStart + search.paddingEnd + group.paddingStart + group.paddingEnd + group.chipSpacingHorizontal + } + init() + } + + override fun onDestroyView() { + super.onDestroyView() + viewModel.cleanup() + } + + private fun init() { + val arguments = arguments + if (arguments != null) { + val fragmentArgs = UserSearchFragmentArgs.fromBundle(arguments) + actionLabel = fragmentArgs.actionLabel + title = fragmentArgs.title + multiple = fragmentArgs.multiple + viewModel.setHideThreadIds(fragmentArgs.hideThreadIds) + viewModel.setHideUserIds(fragmentArgs.hideUserIds) + viewModel.setSearchMode(fragmentArgs.searchMode) + viewModel.setShowGroups(fragmentArgs.showGroups) + } + setupTitles() + setupInput() + setupResults() + setupObservers() + // show cached results + viewModel.showCachedResults() + } + + private fun setupTitles() { + if (!actionLabel.isNullOrBlank()) { + binding.done.text = actionLabel + } + if (title.isNullOrBlank()) return + (activity as MainActivity?)?.supportActionBar?.title = title + } + + private fun setupResults() { + val context = context ?: return + binding.results.layoutManager = LinearLayoutManager(context) + resultsAdapter = UserSearchResultsAdapter(multiple) { _: Int, recipient: RankedRecipient, selected: Boolean -> + if (!multiple) { + val navController = NavHostFragment.findNavController(this) + if (!setResult(navController, recipient)) return@UserSearchResultsAdapter + navController.navigateUp() + return@UserSearchResultsAdapter + } + viewModel.setSelectedRecipient(recipient, !selected) + resultsAdapter?.setSelectedRecipient(recipient, !selected) + if (!selected) { + createChip(recipient) + return@UserSearchResultsAdapter + } + val chip = findChip(recipient) ?: return@UserSearchResultsAdapter + removeChipFromGroup(chip) + } + binding.results.adapter = resultsAdapter + binding.done.setOnClickListener { + val navController = NavHostFragment.findNavController(this) + if (!setResult(navController, viewModel.selectedRecipients)) return@setOnClickListener + navController.navigateUp() + } + } + + private fun setResult(navController: NavController, rankedRecipient: RankedRecipient): Boolean { + navController.previousBackStackEntry?.savedStateHandle?.set("result", rankedRecipient) ?: return false + return true + } + + private fun setResult(navController: NavController, rankedRecipients: Set): Boolean { + navController.previousBackStackEntry?.savedStateHandle?.set("result", rankedRecipients) ?: return false + return true + } + + private fun setupInput() { + binding.search.addTextChangedListener(object : TextWatcherAdapter() { + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + viewModel.search(s.toString().trimAll()) + } + }) + binding.search.setOnKeyListener { _: View?, _: Int, event: KeyEvent? -> + if (event != null && event.action == KeyEvent.ACTION_DOWN && event.keyCode == KeyEvent.KEYCODE_DEL) { + val chip = lastChip ?: return@setOnKeyListener false + removeChip(chip) + } + false + } + binding.group.setOnHierarchyChangeListener(object : OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View, child: View) {} + override fun onChildViewRemoved(parent: View, child: View) { + binding.group.post { + TransitionManager.beginDelayedTransition(binding.root) + calculateInputWidth(0) + } + } + }) + } + + private fun setupObservers() { + viewModel.recipients.observe(viewLifecycleOwner) { + if (it == null) return@observe + when (it.status) { + Resource.Status.SUCCESS -> if (it.data != null) { + resultsAdapter?.submitList(it.data) + } + Resource.Status.ERROR -> { + if (it.message != null) { + Snackbar.make(binding.root, it.message, Snackbar.LENGTH_LONG).show() + } + if (it.resId != 0) { + Snackbar.make(binding.root, it.resId, Snackbar.LENGTH_LONG).show() + } + if (it.data != null) { + resultsAdapter?.submitList(it.data) + } + } + Resource.Status.LOADING -> if (it.data != null) { + resultsAdapter?.submitList(it.data) + } + } + } + viewModel.showAction().observe(viewLifecycleOwner) { binding.done.visibility = if (it) View.VISIBLE else View.GONE } + } + + private fun createChip(recipient: RankedRecipient) { + val context = context ?: return + val chip = Chip(context).apply { + tag = recipient + text = getRecipientText(recipient) + isCloseIconVisible = true + setOnCloseIconClickListener { removeChip(this) } + } + binding.group.post { + val measure = measure(chip, binding.group) + TransitionManager.beginDelayedTransition(binding.root) + calculateInputWidth(if (measure.second != null) measure.second else 0) + binding.group.addView(chip, binding.group.childCount - 1) + } + } + + private fun getRecipientText(recipient: RankedRecipient?): String? = when { + recipient == null -> null + recipient.user != null -> recipient.user.fullName + recipient.thread != null -> recipient.thread.threadTitle + else -> null + } + + private fun removeChip(chip: View) { + val recipient = chip.tag as RankedRecipient + viewModel.setSelectedRecipient(recipient, false) + resultsAdapter?.setSelectedRecipient(recipient, false) + removeChipFromGroup(chip) + } + + private fun findChip(recipient: RankedRecipient?): View? { + if (recipient == null || recipient.user == null && recipient.thread == null) return null + val isUser = recipient.user != null + val childCount = binding.group.childCount + if (childCount == 0) return null + for (i in childCount - 1 downTo 0) { + val child = binding.group.getChildAt(i) ?: continue + val tag = child.tag as RankedRecipient + if (isUser && tag.user == null || !isUser && tag.thread == null) continue + if (isUser && tag.user?.pk == recipient.user?.pk || !isUser && tag.thread?.threadId == recipient.thread?.threadId) { + return child + } + } + return null + } + + private fun removeChipFromGroup(chip: View) { + binding.group.post { + TransitionManager.beginDelayedTransition(binding.root) + binding.group.removeView(chip) + } + } + + private fun calculateInputWidth(newChipWidth: Int) { + var lastRight = lastChip?.right ?: 0 + val remainingSpaceInRow = windowWidth - lastRight + if (remainingSpaceInRow < newChipWidth) { + // next chip will go to the next row, so assume no chips present + lastRight = 0 + } + val newRight = lastRight + newChipWidth + val newInputWidth = windowWidth - newRight - paddingOffset + binding.search.layoutParams.width = if (newInputWidth < minInputWidth) windowWidth else newInputWidth + binding.search.requestLayout() + } + + private val lastChip: View? + get() { + val childCount = binding.group.childCount + if (childCount == 0) return null + for (i in childCount - 1 downTo 0) { + val child = binding.group.getChildAt(i) + if (child is Chip) { + return child + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/UserSearchMode.kt b/app/src/main/java/awais/instagrabber/fragments/UserSearchMode.kt new file mode 100644 index 0000000..fc0e919 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/UserSearchMode.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.fragments + +enum class UserSearchMode(val mode: String) { + USER_SEARCH("user_name"), + RAVEN("raven"), + RESHARE("reshare"); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/comments/CommentsViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/comments/CommentsViewerFragment.java new file mode 100644 index 0000000..6bc8643 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/comments/CommentsViewerFragment.java @@ -0,0 +1,246 @@ +package awais.instagrabber.fragments.comments; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.RelativeSizeSpan; +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.constraintlayout.widget.ConstraintLayout; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.snackbar.Snackbar; + +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.CommentsAdapter; +import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; +import awais.instagrabber.databinding.FragmentCommentsBinding; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.models.Comment; +import awais.instagrabber.models.Resource; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.AppStateViewModel; +import awais.instagrabber.viewmodels.CommentsViewerViewModel; + +public final class CommentsViewerFragment extends BottomSheetDialogFragment { + private static final String TAG = CommentsViewerFragment.class.getSimpleName(); + + private CommentsViewerViewModel viewModel; + private CommentsAdapter commentsAdapter; + private FragmentCommentsBinding binding; + private ConstraintLayout root; + private boolean shouldRefresh = true; + private AppStateViewModel appStateViewModel; + private boolean showingReplies; + + @Override + public void onStart() { + super.onStart(); + final Dialog dialog = getDialog(); + if (dialog == null) return; + final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; + final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + if (bottomSheetInternal == null) return; + bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + bottomSheetInternal.requestLayout(); + final BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheetInternal); + behavior.setState(BottomSheetBehavior.STATE_EXPANDED); + behavior.setSkipCollapsed(true); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentActivity activity = getActivity(); + if (activity == null) return; + viewModel = new ViewModelProvider(this).get(CommentsViewerViewModel.class); + appStateViewModel = new ViewModelProvider(activity).get(AppStateViewModel.class); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + return new BottomSheetDialog(requireContext(), getTheme()) { + @Override + public void onBackPressed() { + if (showingReplies) { + getChildFragmentManager().popBackStack(); + showingReplies = false; + return; + } + super.onBackPressed(); + } + }; + } + + @NonNull + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentCommentsBinding.inflate(getLayoutInflater()); + binding.swipeRefreshLayout.setEnabled(false); + binding.swipeRefreshLayout.setNestedScrollingEnabled(false); + root = binding.getRoot(); + appStateViewModel.getCurrentUserLiveData().observe(getViewLifecycleOwner(), userResource -> { + if (userResource == null || userResource.data == null) return; + viewModel.setCurrentUser(userResource.data); + }); + if (getArguments() == null) return root; + final CommentsViewerFragmentArgs args = CommentsViewerFragmentArgs.fromBundle(getArguments()); + viewModel.setPostDetails(args.getShortCode(), args.getPostId(), args.getPostUserId()); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + shouldRefresh = false; + init(); + } + + private void init() { + setupToolbar(); + setupList(); + setupObservers(); + } + + private void setupObservers() { + viewModel.getCurrentUserId().observe(getViewLifecycleOwner(), currentUserId -> { + long userId = 0; + if (currentUserId != null) { + userId = currentUserId; + } + setupAdapter(userId); + if (userId == 0) return; + Helper.setupCommentInput(binding.commentField, binding.commentText, false, text -> { + final LiveData> resourceLiveData = viewModel.comment(text, false); + resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(final Resource objectResource) { + if (objectResource == null) return; + final Context context = getContext(); + if (context == null) return; + Helper.handleCommentResource( + context, + objectResource.status, + objectResource.message, + resourceLiveData, + this, + binding.commentField, + binding.commentText, + binding.comments); + } + }); + return null; + }); + }); + viewModel.getRootList().observe(getViewLifecycleOwner(), listResource -> { + if (listResource == null) return; + switch (listResource.status) { + case SUCCESS: + binding.swipeRefreshLayout.setRefreshing(false); + if (commentsAdapter != null) { + commentsAdapter.submitList(listResource.data); + } + break; + case ERROR: + binding.swipeRefreshLayout.setRefreshing(false); + if (!TextUtils.isEmpty(listResource.message)) { + Snackbar.make(binding.getRoot(), listResource.message, Snackbar.LENGTH_LONG).show(); + } + break; + case LOADING: + binding.swipeRefreshLayout.setRefreshing(true); + break; + } + }); + viewModel.getRootCommentsCount().observe(getViewLifecycleOwner(), count -> { + if (count == null || count == 0) { + binding.toolbar.setTitle(R.string.title_comments); + return; + } + final String titleComments = getString(R.string.title_comments); + final String countString = String.valueOf(count); + final SpannableString titleWithCount = new SpannableString(String.format("%s %s", titleComments, countString)); + titleWithCount.setSpan(new RelativeSizeSpan(0.8f), + titleWithCount.length() - countString.length(), + titleWithCount.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + binding.toolbar.setTitle(titleWithCount); + }); + } + + private void setupToolbar() { + binding.toolbar.setTitle(R.string.title_comments); + } + + private void setupAdapter(final long currentUserId) { + final Context context = getContext(); + if (context == null) return; + commentsAdapter = new CommentsAdapter(currentUserId, false, Helper.getCommentCallback( + context, + getViewLifecycleOwner(), + getNavController(), + viewModel, + (comment, focusInput) -> { + if (comment == null) return null; + final boolean disableTransition = Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_DISABLE_SCREEN_TRANSITIONS); + final RepliesFragment repliesFragment = RepliesFragment.newInstance(comment, focusInput != null && focusInput); + final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); + if (!disableTransition) { + transaction.setCustomAnimations(R.anim.slide_left, R.anim.slide_right, 0, R.anim.slide_right); + } + transaction.add(R.id.replies_container_view, repliesFragment) + .addToBackStack(RepliesFragment.TAG) + .commit(); + showingReplies = true; + return null; + })); + final Resource> listResource = viewModel.getRootList().getValue(); + binding.comments.setAdapter(commentsAdapter); + commentsAdapter.submitList(listResource != null ? listResource.data : Collections.emptyList()); + } + + private void setupList() { + final Context context = getContext(); + if (context == null) return; + final LinearLayoutManager layoutManager = new LinearLayoutManager(context); + final RecyclerLazyLoader lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> viewModel.fetchComments()); + Helper.setupList(context, binding.comments, layoutManager, lazyLoader); + } + + @Nullable + private NavController getNavController() { + NavController navController = null; + try { + navController = NavHostFragment.findNavController(this); + } catch (IllegalStateException e) { + Log.e(TAG, "navigateToProfile", e); + } + return navController; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/comments/Helper.java b/app/src/main/java/awais/instagrabber/fragments/comments/Helper.java new file mode 100644 index 0000000..f3aabf5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/comments/Helper.java @@ -0,0 +1,285 @@ +package awais.instagrabber.fragments.comments; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.Editable; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.internal.CheckableImageButton; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.function.BiFunction; +import java.util.function.Function; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.CommentsAdapter.CommentCallback; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.models.Comment; +import awais.instagrabber.models.Resource; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.CommentsViewerViewModel; +import awais.instagrabber.webservices.ServiceCallback; + +public final class Helper { + private static final String TAG = Helper.class.getSimpleName(); + + public static void setupList(@NonNull final Context context, + @NonNull final RecyclerView list, + @NonNull final RecyclerView.LayoutManager layoutManager, + @NonNull final RecyclerView.OnScrollListener lazyLoader) { + list.setLayoutManager(layoutManager); + final DividerItemDecoration itemDecoration = new DividerItemDecoration(context, LinearLayoutManager.VERTICAL); + final Drawable drawable = ContextCompat.getDrawable(context, R.drawable.pref_list_divider_material); + if (drawable != null) { + itemDecoration.setDrawable(drawable); + } + list.addItemDecoration(itemDecoration); + list.addOnScrollListener(lazyLoader); + } + + @NonNull + public static CommentCallback getCommentCallback(@NonNull final Context context, + final LifecycleOwner lifecycleOwner, + final NavController navController, + @NonNull final CommentsViewerViewModel viewModel, + final BiFunction onRepliesClick) { + return new CommentCallback() { + @Override + public void onClick(final Comment comment) { + // onCommentClick(comment); + if (onRepliesClick == null) return; + onRepliesClick.apply(comment, false); + } + + @Override + public void onHashtagClick(final String hashtag) { + try { + if (navController == null) return; + navController.navigate(CommentsViewerFragmentDirections.actionToHashtag(hashtag)); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onMentionClick(final String mention) { + openProfile(navController, mention); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(context, url); + } + + @Override + public void onEmailClick(final String emailAddress) { + Utils.openEmailAddress(context, emailAddress); + } + + @Override + public void onLikeClick(final Comment comment, final boolean liked, final boolean isReply) { + if (comment == null) return; + final LiveData> resourceLiveData = viewModel.likeComment(comment, liked, isReply); + resourceLiveData.observe(lifecycleOwner, new Observer>() { + @Override + public void onChanged(final Resource objectResource) { + if (objectResource == null) return; + switch (objectResource.status) { + case SUCCESS: + resourceLiveData.removeObserver(this); + break; + case LOADING: + break; + case ERROR: + if (objectResource.message != null) { + Toast.makeText(context, objectResource.message, Toast.LENGTH_LONG).show(); + } + resourceLiveData.removeObserver(this); + } + } + }); + } + + @Override + public void onRepliesClick(final Comment comment) { + // viewModel.showReplies(comment); + if (onRepliesClick == null) return; + onRepliesClick.apply(comment, true); + } + + @Override + public void onViewLikes(final Comment comment) { + try { + if (navController == null) return; + final NavDirections actionToLikes = CommentsViewerFragmentDirections.actionToLikes(comment.getPk(), true); + navController.navigate(actionToLikes); + } catch (Exception e) { + Log.e(TAG, "onViewLikes: ", e); + } + } + + @Override + public void onTranslate(final Comment comment) { + if (comment == null) return; + viewModel.translate(comment, new ServiceCallback() { + @Override + public void onSuccess(final String result) { + if (TextUtils.isEmpty(result)) { + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); + return; + } + final String username = comment.getUser().getUsername(); + new MaterialAlertDialogBuilder(context) + .setTitle(username) + .setMessage(result) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error translating comment", t); + Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + @Override + public void onDelete(final Comment comment, final boolean isReply) { + if (comment == null) return; + final LiveData> resourceLiveData = viewModel.deleteComment(comment, isReply); + resourceLiveData.observe(lifecycleOwner, new Observer>() { + @Override + public void onChanged(final Resource objectResource) { + if (objectResource == null) return; + switch (objectResource.status) { + case SUCCESS: + resourceLiveData.removeObserver(this); + break; + case ERROR: + if (objectResource.message != null) { + Toast.makeText(context, objectResource.message, Toast.LENGTH_LONG).show(); + } + resourceLiveData.removeObserver(this); + break; + case LOADING: + break; + } + } + }); + } + }; + } + + private static void openProfile(final NavController navController, + @NonNull final String username) { + try { + if (navController == null) return; + final NavDirections action = CommentsViewerFragmentDirections.actionToProfile().setUsername(username); + navController.navigate(action); + } catch (Exception e) { + Log.e(TAG, "openProfile: ", e); + } + } + + public static void setupCommentInput(@NonNull final TextInputLayout commentField, + @NonNull final TextInputEditText commentText, + final boolean isReplyFragment, + @NonNull final Function commentFunction) { + // commentField.setStartIconVisible(false); + commentField.setVisibility(View.VISIBLE); + commentField.setEndIconVisible(false); + if (isReplyFragment) { + commentField.setHint(R.string.reply_hint); + } + commentText.addTextChangedListener(new TextWatcherAdapter() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + final boolean isEmpty = TextUtils.isEmpty(s); + commentField.setStartIconVisible(!isEmpty); + commentField.setEndIconVisible(!isEmpty); + commentField.setCounterEnabled(s != null && s.length() > 2000); // show the counter when user approaches the limit + } + }); + // commentField.setStartIconOnClickListener(v -> { + // // commentsAdapter.clearSelection(); + // commentText.setText(""); + // }); + commentField.setEndIconOnClickListener(v -> { + final Editable text = commentText.getText(); + if (TextUtils.isEmpty(text)) return; + commentFunction.apply(text.toString().trim()); + }); + } + + public static void handleCommentResource(@NonNull final Context context, + @NonNull final Resource.Status status, + final String message, + @NonNull final LiveData> resourceLiveData, + @NonNull final Observer> observer, + @NonNull final TextInputLayout commentField, + @NonNull final TextInputEditText commentText, + @NonNull final RecyclerView comments) { + CheckableImageButton endIcon = null; + try { + endIcon = (CheckableImageButton) commentField.findViewById(com.google.android.material.R.id.text_input_end_icon); + } catch (Exception e) { + Log.e(TAG, "setupObservers: ", e); + } + CheckableImageButton startIcon = null; + try { + startIcon = (CheckableImageButton) commentField.findViewById(com.google.android.material.R.id.text_input_start_icon); + } catch (Exception e) { + Log.e(TAG, "setupObservers: ", e); + } + switch (status) { + case SUCCESS: + resourceLiveData.removeObserver(observer); + comments.postDelayed(() -> comments.scrollToPosition(0), 500); + if (startIcon != null) { + startIcon.setEnabled(true); + } + if (endIcon != null) { + endIcon.setEnabled(true); + } + commentText.setText(""); + break; + case LOADING: + commentText.setEnabled(false); + if (startIcon != null) { + startIcon.setEnabled(false); + } + if (endIcon != null) { + endIcon.setEnabled(false); + } + break; + case ERROR: + if (message != null && context != null) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show(); + } + if (startIcon != null) { + startIcon.setEnabled(true); + } + if (endIcon != null) { + endIcon.setEnabled(true); + } + resourceLiveData.removeObserver(observer); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/comments/RepliesFragment.java b/app/src/main/java/awais/instagrabber/fragments/comments/RepliesFragment.java new file mode 100644 index 0000000..1122e74 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/comments/RepliesFragment.java @@ -0,0 +1,242 @@ +package awais.instagrabber.fragments.comments; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.google.android.material.snackbar.Snackbar; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.CommentsAdapter; +import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; +import awais.instagrabber.databinding.FragmentCommentsBinding; +import awais.instagrabber.models.Comment; +import awais.instagrabber.models.Resource; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.CommentsViewerViewModel; + +public class RepliesFragment extends Fragment { + public static final String TAG = RepliesFragment.class.getSimpleName(); + private static final String ARG_PARENT = "parent"; + private static final String ARG_FOCUS_INPUT = "focus"; + + private FragmentCommentsBinding binding; + private CommentsViewerViewModel viewModel; + private CommentsAdapter commentsAdapter; + + @NonNull + public static RepliesFragment newInstance(@NonNull final Comment parent, + final boolean focusInput) { + final Bundle args = new Bundle(); + args.putSerializable(ARG_PARENT, parent); + args.putBoolean(ARG_FOCUS_INPUT, focusInput); + final RepliesFragment fragment = new RepliesFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Fragment parentFragment = getParentFragment(); + if (parentFragment == null) return; + viewModel = new ViewModelProvider(parentFragment).get(CommentsViewerViewModel.class); + final Bundle bundle = getArguments(); + if (bundle == null) return; + final Serializable serializable = bundle.getSerializable(ARG_PARENT); + if (!(serializable instanceof Comment)) return; + viewModel.showReplies((Comment) serializable); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = FragmentCommentsBinding.inflate(inflater, container, false); + binding.swipeRefreshLayout.setEnabled(false); + binding.swipeRefreshLayout.setNestedScrollingEnabled(false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + setupToolbar(); + } + + @Override + public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) { + if (!enter) { + return super.onCreateAnimation(transit, false, nextAnim); + } + if (nextAnim == 0) { + setupList(); + setupObservers(); + return super.onCreateAnimation(transit, true, nextAnim); + } + final Animation animation = AnimationUtils.loadAnimation(getContext(), nextAnim); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + setupList(); + setupObservers(); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + return animation; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (viewModel != null) { + viewModel.clearReplies(); + } + } + + private void setupObservers() { + if (viewModel == null) return; + viewModel.getCurrentUserId().observe(getViewLifecycleOwner(), currentUserId -> { + long userId = 0; + if (currentUserId != null) { + userId = currentUserId; + } + setupAdapter(userId); + if (userId == 0) return; + Helper.setupCommentInput(binding.commentField, binding.commentText, true, text -> { + final LiveData> resourceLiveData = viewModel.comment(text, true); + resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(final Resource objectResource) { + if (objectResource == null) return; + final Context context = getContext(); + if (context == null) return; + Helper.handleCommentResource(context, + objectResource.status, + objectResource.message, + resourceLiveData, + this, + binding.commentField, + binding.commentText, + binding.comments); + } + }); + return null; + }); + final Bundle bundle = getArguments(); + if (bundle == null) return; + final boolean focusInput = bundle.getBoolean(ARG_FOCUS_INPUT); + if (focusInput && viewModel.getRepliesParent() != null) { + viewModel.getRepliesParent().getUser(); + binding.commentText.setText(String.format("@%s ", viewModel.getRepliesParent().getUser().getUsername())); + Utils.showKeyboard(binding.commentText); + } + }); + viewModel.getReplyList().observe(getViewLifecycleOwner(), listResource -> { + if (listResource == null) return; + switch (listResource.status) { + case SUCCESS: + binding.swipeRefreshLayout.setRefreshing(false); + if (commentsAdapter != null) { + commentsAdapter.submitList(listResource.data); + } + break; + case ERROR: + binding.swipeRefreshLayout.setRefreshing(false); + final String message = listResource.message; + if (!TextUtils.isEmpty(message)) { + Snackbar.make(binding.getRoot(), message, Snackbar.LENGTH_LONG).show(); + } + break; + case LOADING: + binding.swipeRefreshLayout.setRefreshing(true); + break; + } + }); + } + + private void setupToolbar() { + binding.toolbar.setTitle(R.string.title_replies); + binding.toolbar.setNavigationIcon(R.drawable.ic_round_arrow_back_24); + binding.toolbar.setNavigationOnClickListener(v -> { + final FragmentManager fragmentManager = getParentFragmentManager(); + fragmentManager.popBackStack(); + }); + } + + private void setupAdapter(final long currentUserId) { + if (viewModel == null) return; + final Context context = getContext(); + if (context == null) return; + commentsAdapter = new CommentsAdapter( + currentUserId, + true, + Helper.getCommentCallback( + context, + getViewLifecycleOwner(), + getNavController(), + viewModel, + (comment, focusInput) -> { + viewModel.setReplyTo(comment); + binding.commentText.setText(String.format("@%s ", comment.getUser().getUsername())); + if (focusInput) Utils.showKeyboard(binding.commentText); + return null; + } + ) + ); + binding.comments.setAdapter(commentsAdapter); + final Resource> listResource = viewModel.getReplyList().getValue(); + commentsAdapter.submitList(listResource != null ? listResource.data : Collections.emptyList()); + } + + private void setupList() { + final Context context = getContext(); + if (context == null) return; + final LinearLayoutManager layoutManager = new LinearLayoutManager(context); + final RecyclerLazyLoader lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> { + if (viewModel != null) viewModel.fetchReplies(); + }); + Helper.setupList(context, binding.comments, layoutManager, lazyLoader); + } + + @Nullable + private NavController getNavController() { + NavController navController = null; + try { + navController = NavHostFragment.findNavController(this); + } catch (IllegalStateException e) { + Log.e(TAG, "navigateToProfile", e); + } + return navController; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.kt b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.kt new file mode 100644 index 0000000..de7a820 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.kt @@ -0,0 +1,200 @@ +package awais.instagrabber.fragments.directmessages + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.* +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import awais.instagrabber.R +import awais.instagrabber.activities.MainActivity +import awais.instagrabber.adapters.DirectMessageInboxAdapter +import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge +import awais.instagrabber.databinding.FragmentDirectMessagesInboxBinding +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.directmessages.DirectInbox +import awais.instagrabber.repositories.responses.directmessages.DirectThread +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.viewmodels.DirectInboxViewModel +import com.google.android.material.badge.BadgeDrawable +import com.google.android.material.badge.BadgeUtils +import com.google.android.material.internal.ToolbarUtils +import com.google.android.material.snackbar.Snackbar + +class DirectMessageInboxFragment : Fragment(), OnRefreshListener { + private val viewModel: DirectInboxViewModel by activityViewModels() + + private lateinit var fragmentActivity: MainActivity + private lateinit var binding: FragmentDirectMessagesInboxBinding + private lateinit var lazyLoader: RecyclerLazyLoaderAtEdge + + private var scrollToTop = false + private var navigating = false + + private var pendingRequestsMenuItem: MenuItem? = null + private var pendingRequestTotalBadgeDrawable: BadgeDrawable? = null + private var isPendingRequestTotalBadgeAttached = false + private var inboxAdapter: DirectMessageInboxAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + fragmentActivity = requireActivity() as MainActivity + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = FragmentDirectMessagesInboxBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + init() + } + + override fun onRefresh() { + lazyLoader.resetState() + scrollToTop = true + viewModel.refresh() + } + + @SuppressLint("UnsafeExperimentalUsageError", "UnsafeOptInUsageError", "RestrictedApi") + override fun onPause() { + super.onPause() + isPendingRequestTotalBadgeAttached = false + pendingRequestsMenuItem?.let { + val menuItemView = ToolbarUtils.getActionMenuItemView(fragmentActivity.getToolbar(), it.itemId) + if (menuItemView != null) { + BadgeUtils.detachBadgeDrawable(pendingRequestTotalBadgeDrawable, fragmentActivity.getToolbar(), it.itemId) + pendingRequestTotalBadgeDrawable = null + } + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.dm_inbox_menu, menu) + pendingRequestsMenuItem = menu.findItem(R.id.pending_requests) + pendingRequestsMenuItem?.isVisible = isPendingRequestTotalBadgeAttached + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.pending_requests) { + try { + val directions = DirectMessageInboxFragmentDirections.actionToPendingInbox() + findNavController().navigate(directions) + } catch (e: Exception) { + Log.e(TAG, "onOptionsItemSelected: ", e) + } + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + init() + } + + private fun setupObservers() { + viewModel.threads.observe(viewLifecycleOwner, { list: List -> + inboxAdapter?.submitList(list) { + if (!scrollToTop) return@submitList + binding.inboxList.post { binding.inboxList.smoothScrollToPosition(0) } + scrollToTop = false + } + }) + viewModel.inbox.observe(viewLifecycleOwner, { inboxResource: Resource? -> + if (inboxResource == null) return@observe + when (inboxResource.status) { + Resource.Status.SUCCESS -> binding.swipeRefreshLayout.isRefreshing = false + Resource.Status.ERROR -> { + if (inboxResource.message != null) { + Snackbar.make(binding.root, inboxResource.message, Snackbar.LENGTH_LONG).show() + } + if (inboxResource.resId != 0) { + Snackbar.make(binding.root, inboxResource.resId, Snackbar.LENGTH_LONG).show() + } + binding.swipeRefreshLayout.isRefreshing = false + } + Resource.Status.LOADING -> binding.swipeRefreshLayout.isRefreshing = true + } + }) + viewModel.pendingRequestsTotal.observe(viewLifecycleOwner, { count: Int? -> attachPendingRequestsBadge(count) }) + } + + @SuppressLint("UnsafeExperimentalUsageError", "UnsafeOptInUsageError", "RestrictedApi") + private fun attachPendingRequestsBadge(count: Int?) { + val pendingRequestsMenuItem1 = pendingRequestsMenuItem + if (pendingRequestsMenuItem1 == null) { + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ attachPendingRequestsBadge(count) }, 500) + return + } + if (pendingRequestTotalBadgeDrawable == null) { + val context = context ?: return + pendingRequestTotalBadgeDrawable = BadgeDrawable.create(context) + } + if (count == null || count == 0) { + val menuItemView = ToolbarUtils.getActionMenuItemView( + fragmentActivity.getToolbar(), + pendingRequestsMenuItem1.itemId + ) + if (menuItemView != null) { + BadgeUtils.detachBadgeDrawable(pendingRequestTotalBadgeDrawable, fragmentActivity.getToolbar(), pendingRequestsMenuItem1.itemId) + } + isPendingRequestTotalBadgeAttached = false + pendingRequestTotalBadgeDrawable?.number = 0 + pendingRequestsMenuItem1.isVisible = false + return + } + pendingRequestsMenuItem1.isVisible = true + if (pendingRequestTotalBadgeDrawable?.number == count) return + pendingRequestTotalBadgeDrawable?.number = count + if (!isPendingRequestTotalBadgeAttached) { + pendingRequestTotalBadgeDrawable?.let { + BadgeUtils.attachBadgeDrawable(it, fragmentActivity.getToolbar(), pendingRequestsMenuItem1.itemId) + isPendingRequestTotalBadgeAttached = true + } + } + } + + private fun init() { + val context = context ?: return + setupObservers() + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.inboxList.setHasFixedSize(true) + binding.inboxList.setItemViewCacheSize(20) + val layoutManager = LinearLayoutManager(context) + binding.inboxList.layoutManager = layoutManager + inboxAdapter = DirectMessageInboxAdapter { thread -> + val threadId = thread.threadId + val threadTitle = thread.threadTitle + if (navigating || threadId.isNullOrBlank() || threadTitle.isNullOrBlank()) return@DirectMessageInboxAdapter + navigating = true + if (isAdded) { + try { + val directions = DirectMessageInboxFragmentDirections.actionToThread(threadId, threadTitle) + findNavController().navigate(directions) + } catch (e: Exception) { + Log.e(TAG, "init: ", e) + } + } + navigating = false + }.also { + it.setHasStableIds(true) + } + binding.inboxList.adapter = inboxAdapter + lazyLoader = RecyclerLazyLoaderAtEdge(layoutManager) { viewModel.fetchInbox() }.also { + binding.inboxList.addOnScrollListener(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt new file mode 100644 index 0000000..83fe327 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt @@ -0,0 +1,482 @@ +package awais.instagrabber.fragments.directmessages + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import awais.instagrabber.R +import awais.instagrabber.activities.MainActivity +import awais.instagrabber.adapters.DirectPendingUsersAdapter +import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUser +import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUserCallback +import awais.instagrabber.adapters.DirectUsersAdapter +import awais.instagrabber.customviews.helpers.TextWatcherAdapter +import awais.instagrabber.databinding.FragmentDirectMessagesSettingsBinding +import awais.instagrabber.dialogs.ConfirmDialogFragment +import awais.instagrabber.dialogs.ConfirmDialogFragment.ConfirmDialogFragmentCallback +import awais.instagrabber.dialogs.MultiOptionDialogFragment +import awais.instagrabber.dialogs.MultiOptionDialogFragment.MultiOptionDialogSingleCallback +import awais.instagrabber.fragments.UserSearchMode +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.DirectThreadParticipantRequestsResponse +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.viewmodels.AppStateViewModel +import awais.instagrabber.viewmodels.DirectSettingsViewModel +import awais.instagrabber.viewmodels.factories.DirectSettingsViewModelFactory +import com.google.android.material.snackbar.Snackbar +import java.util.* + +class DirectMessageSettingsFragment : Fragment(), ConfirmDialogFragmentCallback { + private lateinit var viewModel: DirectSettingsViewModel + private lateinit var binding: FragmentDirectMessagesSettingsBinding + + private var usersAdapter: DirectUsersAdapter? = null + private var isPendingRequestsSetupDone = false + private var pendingUsersAdapter: DirectPendingUsersAdapter? = null + private var approvalRequiredUsers: Set? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val arguments = arguments ?: return + val args = DirectMessageSettingsFragmentArgs.fromBundle(arguments) + val fragmentActivity = requireActivity() as MainActivity + val appStateViewModel: AppStateViewModel by activityViewModels() + val currentUser = appStateViewModel.currentUser?.data ?: return + val viewModelFactory = DirectSettingsViewModelFactory( + fragmentActivity.application, + args.threadId, + args.pending, + currentUser + ) + viewModel = ViewModelProvider(this, viewModelFactory).get(DirectSettingsViewModel::class.java) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = FragmentDirectMessagesSettingsBinding.inflate(inflater, container, false) + // currentlyRunning = new DirectMessageInboxThreadFetcher(threadId, null, null, fetchListener).execute(); + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + init() + setupObservers() + } + + override fun onDestroyView() { + super.onDestroyView() + isPendingRequestsSetupDone = false + } + + private fun setupObservers() { + viewModel.inputMode.observe(viewLifecycleOwner, { inputMode: Int? -> + if (inputMode == null || inputMode == 0) return@observe + if (inputMode == 1) { + binding.groupSettings.visibility = View.GONE + binding.pendingMembersGroup.visibility = View.GONE + binding.approvalRequired.visibility = View.GONE + binding.approvalRequiredLabel.visibility = View.GONE + binding.muteMessagesLabel.visibility = View.GONE + binding.muteMessages.visibility = View.GONE + } + }) + // Need to observe, so that getValue is correct + viewModel.getUsers().observe(viewLifecycleOwner, { }) + viewModel.getLeftUsers().observe(viewLifecycleOwner, { }) + viewModel.getUsersAndLeftUsers().observe(viewLifecycleOwner, { usersAdapter?.submitUsers(it.first, it.second) }) + viewModel.getTitle().observe(viewLifecycleOwner, { binding.titleEdit.setText(it) }) + viewModel.getAdminUserIds().observe(viewLifecycleOwner, { usersAdapter?.setAdminUserIds(it) }) + viewModel.isMuted().observe(viewLifecycleOwner, { binding.muteMessages.isChecked = it }) + viewModel.isPending().observe(viewLifecycleOwner, { binding.muteMessages.visibility = if (it) View.GONE else View.VISIBLE }) + viewModel.isViewerAdmin().observe(viewLifecycleOwner, { setApprovalRelatedUI(it) }) + viewModel.getApprovalRequiredToJoin().observe(viewLifecycleOwner, { binding.approvalRequired.isChecked = it }) + viewModel.getPendingRequests().observe(viewLifecycleOwner, { setPendingRequests(it) }) + viewModel.isGroup().observe(viewLifecycleOwner, { isGroup: Boolean -> setupSettings(isGroup) }) + val navController = NavHostFragment.findNavController(this) + val backStackEntry = navController.currentBackStackEntry + if (backStackEntry != null) { + val resultLiveData = backStackEntry.savedStateHandle.getLiveData("result") + resultLiveData.observe(viewLifecycleOwner, { result: Any? -> + if (result == null) return@observe + if (result is RankedRecipient) { + val user = getUser(result) + // Log.d(TAG, "result: " + user); + if (user != null) { + addMembers(setOf(user)) + } + } else if (result is Set<*>) { + try { + @Suppress("UNCHECKED_CAST") val recipients = result as Set + val users: Set = recipients.asSequence() + .filterNotNull() + .map { getUser(it) } + .filterNotNull() + .toSet() + // Log.d(TAG, "result: " + users); + addMembers(users) + } catch (e: Exception) { + Log.e(TAG, "search users result: ", e) + Snackbar.make(binding.root, e.message ?: "", Snackbar.LENGTH_LONG).show() + } + } + }) + } + } + + private fun addMembers(users: Set) { + val approvalRequired = viewModel.getApprovalRequiredToJoin().value + var isViewerAdmin = viewModel.isViewerAdmin().value + if (isViewerAdmin == null) { + isViewerAdmin = false + } + if (!isViewerAdmin && approvalRequired != null && approvalRequired) { + approvalRequiredUsers = users + val confirmDialogFragment = ConfirmDialogFragment.newInstance( + APPROVAL_REQUIRED_REQUEST_CODE, + R.string.admin_approval_required, + R.string.admin_approval_required_description, + R.string.ok, + R.string.cancel, + 0 + ) + confirmDialogFragment.show(childFragmentManager, "approval_required_dialog") + return + } + val detailsChangeResourceLiveData = viewModel.addMembers(users) + observeDetailsChange(detailsChangeResourceLiveData) + } + + private fun getUser(recipient: RankedRecipient): User? { + var user: User? = null + if (recipient.user != null) { + user = recipient.user + } else if (recipient.thread != null && !recipient.thread.isGroup) { + user = recipient.thread.users?.get(0) + } + return user + } + + private fun init() { + // setupSettings(); + setupMembers() + } + + private fun setupSettings(isGroup: Boolean) { + binding.groupSettings.visibility = if (isGroup) View.VISIBLE else View.GONE + binding.muteMessagesLabel.setOnClickListener { binding.muteMessages.toggle() } + binding.muteMessages.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> + val resourceLiveData = if (isChecked) viewModel.mute() else viewModel.unmute() + handleSwitchChangeResource(resourceLiveData, buttonView) + } + if (!isGroup) return + binding.titleEdit.addTextChangedListener(object : TextWatcherAdapter() { + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (s.toString().trim { it <= ' ' } == viewModel.getTitle().value) { + binding.titleEditInputLayout.suffixText = null + return + } + binding.titleEditInputLayout.suffixText = getString(R.string.save) + } + }) + binding.titleEditInputLayout.suffixTextView.setOnClickListener { + val text = binding.titleEdit.text ?: return@setOnClickListener + val newTitle = text.toString().trim { it <= ' ' } + if (newTitle == viewModel.getTitle().value) return@setOnClickListener + observeDetailsChange(viewModel.updateTitle(newTitle)) + } + binding.addMembers.setOnClickListener { + if (!isAdded) return@setOnClickListener + try { + val navController = findNavController() + if (navController.currentDestination?.id != R.id.directMessagesSettingsFragment) return@setOnClickListener + val users = viewModel.getUsers().value ?: return@setOnClickListener + val currentUserIds = users.asSequence().map(User::pk).sorted().toList().toLongArray() + val actionGlobalUserSearch = DirectMessageSettingsFragmentDirections.actionToUserSearch().apply { + title = getString(R.string.add_members) + actionLabel = getString(R.string.add) + hideUserIds = currentUserIds + searchMode = UserSearchMode.RAVEN + multiple = true + } + navController.navigate(actionGlobalUserSearch) + } catch (e: Exception) { + Log.e(TAG, "setupSettings: ", e) + } + } + binding.muteMentionsLabel.setOnClickListener { binding.muteMentions.toggle() } + binding.muteMentions.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> + val resourceLiveData = if (isChecked) viewModel.muteMentions() else viewModel.unmuteMentions() + handleSwitchChangeResource(resourceLiveData, buttonView) + } + binding.leave.setOnClickListener { + val confirmDialogFragment = ConfirmDialogFragment.newInstance( + LEAVE_THREAD_REQUEST_CODE, + R.string.dms_action_leave_question, + 0, + R.string.yes, + R.string.no, + 0 + ) + confirmDialogFragment.show(childFragmentManager, "leave_thread_confirmation_dialog") + } + var isViewerAdmin = viewModel.isViewerAdmin().value + if (isViewerAdmin == null) isViewerAdmin = false + if (isViewerAdmin) { + binding.end.visibility = View.VISIBLE + binding.end.setOnClickListener { + val confirmDialogFragment = ConfirmDialogFragment.newInstance( + END_THREAD_REQUEST_CODE, + R.string.dms_action_end_question, + R.string.dms_action_end_description, + R.string.yes, + R.string.no, + 0 + ) + confirmDialogFragment.show(childFragmentManager, "end_thread_confirmation_dialog") + } + } else { + binding.end.visibility = View.GONE + } + } + + private fun setApprovalRelatedUI(isViewerAdmin: Boolean) { + if (!isViewerAdmin) { + binding.pendingMembersGroup.visibility = View.GONE + binding.approvalRequired.visibility = View.GONE + binding.approvalRequiredLabel.visibility = View.GONE + return + } + binding.approvalRequired.visibility = View.VISIBLE + binding.approvalRequiredLabel.visibility = View.VISIBLE + binding.approvalRequiredLabel.setOnClickListener { binding.approvalRequired.toggle() } + binding.approvalRequired.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> + val resourceLiveData = if (isChecked) viewModel.approvalRequired() else viewModel.approvalNotRequired() + handleSwitchChangeResource(resourceLiveData, buttonView) + } + } + + private fun handleSwitchChangeResource(resourceLiveData: LiveData>, buttonView: CompoundButton) { + resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> + if (resource == null) return@observe + when (resource.status) { + Resource.Status.SUCCESS -> buttonView.isEnabled = true + Resource.Status.ERROR -> { + buttonView.isEnabled = true + buttonView.isChecked = !buttonView.isChecked + if (resource.message != null) { + Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() + } + if (resource.resId != 0) { + Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() + } + } + Resource.Status.LOADING -> buttonView.isEnabled = false + } + }) + } + + private fun setupMembers() { + val context = context ?: return + binding.users.layoutManager = LinearLayoutManager(context) + val inviter = viewModel.getInviter().value + usersAdapter = DirectUsersAdapter( + inviter?.pk ?: -1, + { _: Int, user: User, _: Boolean -> + if (user.username.isBlank() && !user.interopMessagingUserFbid.isNullOrBlank()) { + Utils.openURL(context, "https://facebook.com/" + user.interopMessagingUserFbid) + return@DirectUsersAdapter + } + if (user.username.isBlank()) return@DirectUsersAdapter + try { + val directions = DirectMessageSettingsFragmentDirections.actionToProfile().apply { this.username = user.username } + findNavController().navigate(directions) + } catch (e: Exception) { + Log.e(TAG, "setupMembers: ", e) + } + }, + { _: Int, user: User? -> + val options = viewModel.createUserOptions(user) + if (options.isEmpty()) return@DirectUsersAdapter true + val fragment = MultiOptionDialogFragment.newInstance(0, -1, options) + fragment.setSingleCallback(object : MultiOptionDialogSingleCallback { + override fun onSelect(requestCode: Int, action: String?) { + if (action == null) return + val resourceLiveData = viewModel.doAction(user, action) + if (resourceLiveData != null) { + observeDetailsChange(resourceLiveData) + } + } + + override fun onCancel(requestCode: Int) {} + }) + val fragmentManager = childFragmentManager + fragment.show(fragmentManager, "actions") + true + } + ) + binding.users.adapter = usersAdapter + } + + private fun setPendingRequests(requests: DirectThreadParticipantRequestsResponse?) { + val nullOrEmpty: Boolean = requests?.users?.isNullOrEmpty() ?: true + if (nullOrEmpty) { + binding.pendingMembersGroup.visibility = View.GONE + return + } + if (!isPendingRequestsSetupDone) { + val context = context ?: return + binding.pendingMembers.layoutManager = LinearLayoutManager(context) + pendingUsersAdapter = DirectPendingUsersAdapter(object : PendingUserCallback { + override fun onClick(position: Int, pendingUser: PendingUser) { + try { + val directions = DirectMessageSettingsFragmentDirections.actionToProfile().apply { this.username = pendingUser.user.username } + findNavController().navigate(directions) + } catch (e: Exception) { + Log.e(TAG, "onClick: ", e) + } + } + + override fun onApprove(position: Int, pendingUser: PendingUser) { + val resourceLiveData = viewModel.approveUsers(listOf(pendingUser.user)) + observeApprovalChange(resourceLiveData, position, pendingUser) + } + + override fun onDeny(position: Int, pendingUser: PendingUser) { + val resourceLiveData = viewModel.denyUsers(listOf(pendingUser.user)) + observeApprovalChange(resourceLiveData, position, pendingUser) + } + }) + binding.pendingMembers.adapter = pendingUsersAdapter + binding.pendingMembersGroup.visibility = View.VISIBLE + isPendingRequestsSetupDone = true + } + pendingUsersAdapter?.submitPendingRequests(requests) + } + + private fun observeDetailsChange(resourceLiveData: LiveData>) { + resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> + if (resource == null) return@observe + when (resource.status) { + Resource.Status.SUCCESS, + Resource.Status.LOADING, + -> { + } + Resource.Status.ERROR -> { + if (resource.message != null) { + Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() + } + if (resource.resId != 0) { + Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() + } + } + } + }) + } + + private fun observeApprovalChange( + detailsChangeResourceLiveData: LiveData>, + position: Int, + pendingUser: PendingUser, + ) { + detailsChangeResourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> + if (resource == null) return@observe + when (resource.status) { + Resource.Status.SUCCESS -> { + } + Resource.Status.LOADING -> pendingUser.isInProgress = true + Resource.Status.ERROR -> { + pendingUser.isInProgress = false + if (resource.message != null) { + Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() + } + if (resource.resId != 0) { + Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() + } + } + } + pendingUsersAdapter?.notifyItemChanged(position) + }) + } + + override fun onPositiveButtonClicked(requestCode: Int) { + if (requestCode == APPROVAL_REQUIRED_REQUEST_CODE) { + approvalRequiredUsers?.let { + val detailsChangeResourceLiveData = viewModel.addMembers(it) + observeDetailsChange(detailsChangeResourceLiveData) + } + return + } + if (requestCode == LEAVE_THREAD_REQUEST_CODE) { + val resourceLiveData = viewModel.leave() + resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> + if (resource == null) return@observe + when (resource.status) { + Resource.Status.SUCCESS -> { + val directions = DirectMessageSettingsFragmentDirections.actionToInbox() + NavHostFragment.findNavController(this).navigate(directions) + } + Resource.Status.ERROR -> { + binding.leave.isEnabled = true + if (resource.message != null) { + Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() + } + if (resource.resId != 0) { + Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() + } + } + Resource.Status.LOADING -> binding.leave.isEnabled = false + } + }) + return + } + if (requestCode == END_THREAD_REQUEST_CODE) { + val resourceLiveData = viewModel.end() + resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> + if (resource == null) return@observe + when (resource.status) { + Resource.Status.SUCCESS -> { + } + Resource.Status.ERROR -> { + binding.end.isEnabled = true + if (resource.message != null) { + Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() + } + if (resource.resId != 0) { + Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() + } + } + Resource.Status.LOADING -> binding.end.isEnabled = false + } + }) + } + } + + override fun onNegativeButtonClicked(requestCode: Int) { + if (requestCode == APPROVAL_REQUIRED_REQUEST_CODE) { + approvalRequiredUsers = null + } + } + + override fun onNeutralButtonClicked(requestCode: Int) {} + + companion object { + private const val APPROVAL_REQUIRED_REQUEST_CODE = 200 + private const val LEAVE_THREAD_REQUEST_CODE = 201 + private const val END_THREAD_REQUEST_CODE = 202 + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java new file mode 100644 index 0000000..8f91383 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java @@ -0,0 +1,1547 @@ +package awais.instagrabber.fragments.directmessages; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.text.Editable; +import android.util.Log; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.view.menu.ActionMenuItemView; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsAnimationControlListenerCompat; +import androidx.core.view.WindowInsetsAnimationControllerCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavBackStackEntry; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.transition.TransitionManager; +import androidx.vectordrawable.graphics.drawable.Animatable2Compat; +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; + +import com.google.android.material.badge.BadgeDrawable; +import com.google.android.material.badge.BadgeUtils; +import com.google.android.material.internal.ToolbarUtils; +import com.google.android.material.snackbar.Snackbar; +import com.google.common.collect.ImmutableList; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import awais.instagrabber.R; +import awais.instagrabber.activities.CameraActivity; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.DirectItemsAdapter; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemLongClickListener; +import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemOrHeader; +import awais.instagrabber.adapters.DirectReactionsAdapter; +import awais.instagrabber.adapters.viewholder.directmessages.DirectItemViewHolder; +import awais.instagrabber.animations.CubicBezierInterpolator; +import awais.instagrabber.customviews.InsetsAnimationLinearLayout; +import awais.instagrabber.customviews.KeyNotifyingEmojiEditText; +import awais.instagrabber.customviews.RecordView; +import awais.instagrabber.customviews.Tooltip; +import awais.instagrabber.customviews.emoji.Emoji; +import awais.instagrabber.customviews.emoji.EmojiBottomSheetDialog; +import awais.instagrabber.customviews.emoji.EmojiPicker; +import awais.instagrabber.customviews.helpers.ControlFocusInsetsAnimationCallback; +import awais.instagrabber.customviews.helpers.EmojiPickerInsetsAnimationCallback; +import awais.instagrabber.customviews.helpers.HeaderItemDecoration; +import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge; +import awais.instagrabber.customviews.helpers.SimpleImeAnimationController; +import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.customviews.helpers.TranslateDeferringInsetsAnimationCallback; +import awais.instagrabber.databinding.FragmentDirectMessagesThreadBinding; +import awais.instagrabber.dialogs.DirectItemReactionDialogFragment; +import awais.instagrabber.dialogs.GifPickerBottomDialogFragment; +import awais.instagrabber.fragments.UserSearchMode; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.DirectItemType; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.requests.StoryViewerOptions; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; +import awais.instagrabber.repositories.responses.directmessages.DirectItemLink; +import awais.instagrabber.repositories.responses.directmessages.DirectItemReactions; +import awais.instagrabber.repositories.responses.directmessages.DirectItemReelShare; +import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; +import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.DMUtils; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.PermissionUtils; +import awais.instagrabber.utils.ResponseBodyUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.AppStateViewModel; +import awais.instagrabber.viewmodels.DirectThreadViewModel; +import awais.instagrabber.viewmodels.factories.DirectThreadViewModelFactory; + +public class DirectMessageThreadFragment extends Fragment implements DirectReactionsAdapter.OnReactionClickListener, + EmojiPicker.OnEmojiClickListener { + private static final String TAG = DirectMessageThreadFragment.class.getSimpleName(); + private static final int AUDIO_RECORD_PERM_REQUEST_CODE = 1000; + private static final int CAMERA_REQUEST_CODE = 200; + private static final int FILE_PICKER_REQUEST_CODE = 500; + private static final String TRANSLATION_Y = "translationY"; + + private DirectItemsAdapter itemsAdapter; + private MainActivity fragmentActivity; + private DirectThreadViewModel viewModel; + private InsetsAnimationLinearLayout root; + private boolean shouldRefresh = true; + private List itemOrHeaders; + private FragmentDirectMessagesThreadBinding binding; + private Tooltip tooltip; + private float initialSendX; + private ActionBar actionBar; + private AppStateViewModel appStateViewModel; + private Runnable prevTitleRunnable; + private AnimatorSet animatorSet; + private boolean isRecording; + private DirectItemReactionDialogFragment reactionDialogFragment; + private DirectItem itemToForward; + private MutableLiveData backStackSavedStateResultLiveData; + private int prevLength; + private BadgeDrawable pendingRequestCountBadgeDrawable; + private boolean isPendingRequestCountBadgeAttached = false; + private ItemTouchHelper itemTouchHelper; + private LiveData pendingLiveData; + private LiveData threadLiveData; + private LiveData inputModeLiveData; + private LiveData threadTitleLiveData; + private LiveData> fetchingLiveData; + private LiveData> itemsLiveData; + private LiveData replyToItemLiveData; + private LiveData pendingRequestsCountLiveData; + private LiveData> usersLiveData; + private boolean autoMarkAsSeen = false; + private MenuItem markAsSeenMenuItem; + private DirectItem addReactionItem; + private TranslateDeferringInsetsAnimationCallback inputHolderAnimationCallback; + private TranslateDeferringInsetsAnimationCallback chatsAnimationCallback; + private EmojiPickerInsetsAnimationCallback emojiPickerAnimationCallback; + private boolean hasKbOpenedOnce; + private boolean wasToggled; + private SwipeAndRestoreItemTouchHelperCallback touchHelperCallback; + + private final AppExecutors appExecutors = AppExecutors.INSTANCE; + private final Animatable2Compat.AnimationCallback micToSendAnimationCallback = new Animatable2Compat.AnimationCallback() { + @Override + public void onAnimationEnd(final Drawable drawable) { + AnimatedVectorDrawableCompat.unregisterAnimationCallback(drawable, this); + setSendToMicIcon(); + } + }; + private final Animatable2Compat.AnimationCallback sendToMicAnimationCallback = new Animatable2Compat.AnimationCallback() { + @Override + public void onAnimationEnd(final Drawable drawable) { + AnimatedVectorDrawableCompat.unregisterAnimationCallback(drawable, this); + setMicToSendIcon(); + } + }; + private final DirectItemCallback directItemCallback = new DirectItemCallback() { + @Override + public void onHashtagClick(final String hashtag) { + try { + final NavDirections action = DirectMessageThreadFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onMentionClick(final String mention) { + navigateToUser(mention); + } + + @Override + public void onLocationClick(final long locationId) { + try { + final NavDirections action = DirectMessageThreadFragmentDirections.actionToLocation(locationId); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onLocationClick: ", e); + } + } + + @Override + public void onURLClick(final String url) { + final Context context = getContext(); + if (context == null) return; + Utils.openURL(context, url); + } + + @Override + public void onEmailClick(final String email) { + final Context context = getContext(); + if (context == null) return; + Utils.openEmailAddress(context, email); + } + + @Override + public void onMediaClick(final Media media, final int index) { + if (media.isReelMedia()) { + try { + final String pk = media.getPk(); + if (pk == null) return; + final long mediaId = Long.parseLong(pk); + final User user = media.getUser(); + if (user == null) return; + final String username = user.getUsername(); + final NavDirections action = DirectMessageThreadFragmentDirections.actionToStory(StoryViewerOptions.forStory(mediaId, username)); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onMediaClick (story): ", e); + } + return; + } + try { + final NavDirections actionToPost = DirectMessageThreadFragmentDirections.actionToPost(media, index); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(actionToPost); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + } + + @Override + public void onStoryClick(final DirectItemStoryShare storyShare) { + try { + final String pk = storyShare.getReelId(); + if (pk == null) return; + final long mediaId = Long.parseLong(pk); + final Media media = storyShare.getMedia(); + if (media == null) return; + final User user = media.getUser(); + if (user == null) return; + final String username = user.getUsername(); + final NavDirections action = DirectMessageThreadFragmentDirections.actionToStory(StoryViewerOptions.forUser(mediaId, username)); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onStoryClick: ", e); + } + } + + @Override + public void onReaction(final DirectItem item, final Emoji emoji) { + if (item == null || emoji == null) return; + final LiveData> resourceLiveData = viewModel.sendReaction(item, emoji); + resourceLiveData.observe(getViewLifecycleOwner(), directItemResource -> handleSentMessage(resourceLiveData)); + } + + @Override + public void onReactionClick(final DirectItem item, final int position) { + showReactionsDialog(item); + } + + @Override + public void onOptionSelect(final DirectItem item, final int itemId, final Function cb) { + if (itemId == R.id.unsend) { + handleSentMessage(viewModel.unsend(item)); + return; + } + if (itemId == R.id.forward) { + itemToForward = item; + final NavDirections actionGlobalUserSearch = DirectMessageThreadFragmentDirections + .actionToUserSearch() + .setTitle(getString(R.string.forward)) + .setActionLabel(getString(R.string.send)) + .setShowGroups(true) + .setMultiple(true) + .setSearchMode(UserSearchMode.RAVEN); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(actionGlobalUserSearch); + } + if (itemId == R.id.download) { + downloadItem(item); + return; + } + // otherwise call callback if present + if (cb != null) { + cb.apply(item); + } + } + + @Override + public void onAddReactionListener(final DirectItem item) { + if (item == null) return; + addReactionItem = item; + final EmojiBottomSheetDialog emojiBottomSheetDialog = EmojiBottomSheetDialog.newInstance(); + emojiBottomSheetDialog.show(getChildFragmentManager(), EmojiBottomSheetDialog.TAG); + } + }; + private final DirectItemLongClickListener directItemLongClickListener = position -> { + // viewModel.setSelectedPosition(position); + }; + private final Observer backStackSavedStateObserver = result -> { + if (result == null) return; + if (result instanceof Uri) { + final Uri uri = (Uri) result; + handleSentMessage(viewModel.sendUri(uri)); + } else if ((result instanceof RankedRecipient)) { + // Log.d(TAG, "result: " + result); + if (itemToForward != null) { + viewModel.forward((RankedRecipient) result, itemToForward); + } + } else if ((result instanceof Set)) { + try { + // Log.d(TAG, "result: " + result); + if (itemToForward != null) { + //noinspection unchecked + viewModel.forward((Set) result, itemToForward); + } + } catch (Exception e) { + Log.e(TAG, "forward result: ", e); + } + } + // clear result + backStackSavedStateResultLiveData.postValue(null); + }; + private final MutableLiveData inputLength = new MutableLiveData<>(0); + private final MutableLiveData emojiPickerVisible = new MutableLiveData<>(false); + private final MutableLiveData kbVisible = new MutableLiveData<>(false); + private final OnBackPressedCallback onEmojiPickerBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + emojiPickerVisible.postValue(false); + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + appStateViewModel = new ViewModelProvider(fragmentActivity).get(AppStateViewModel.class); + autoMarkAsSeen = Utils.settingsHelper.getBoolean(PreferenceKeys.DM_MARK_AS_SEEN); + final Bundle arguments = getArguments(); + if (arguments == null) return; + final DirectMessageThreadFragmentArgs fragmentArgs = DirectMessageThreadFragmentArgs.fromBundle(arguments); + final Resource currentUserResource = appStateViewModel.getCurrentUser(); + if (currentUserResource == null) return; + final User currentUser = currentUserResource.data; + if (currentUser == null) return; + final DirectThreadViewModelFactory viewModelFactory = new DirectThreadViewModelFactory( + fragmentActivity.getApplication(), + fragmentArgs.getThreadId(), + fragmentArgs.getPending(), + currentUser + ); + viewModel = new ViewModelProvider(this, viewModelFactory).get(DirectThreadViewModel.class); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentDirectMessagesThreadBinding.inflate(inflater, container, false); + binding.send.setRecordView(binding.recordView); + root = binding.getRoot(); + final Context context = getContext(); + if (context == null) { + return root; + } + tooltip = new Tooltip(context, root, getResources().getColor(R.color.grey_400), getResources().getColor(R.color.black)); + // todo check has camera and remove view + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + // WindowCompat.setDecorFitsSystemWindows(fragmentActivity.getWindow(), false); + if (!shouldRefresh) return; + init(); + binding.send.post(() -> initialSendX = binding.send.getX()); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.dm_thread_menu, menu); + markAsSeenMenuItem = menu.findItem(R.id.mark_as_seen); + if (markAsSeenMenuItem != null) { + if (autoMarkAsSeen) { + markAsSeenMenuItem.setVisible(false); + } else { + markAsSeenMenuItem.setEnabled(false); + } + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.info) { + final Boolean pending = viewModel.isPending().getValue(); + final NavDirections directions = DirectMessageThreadFragmentDirections + .actionToSettings(viewModel.getThreadId(), null) + .setPending(pending != null && pending); + NavHostFragment.findNavController(this).navigate(directions); + return true; + } + if (itemId == R.id.mark_as_seen) { + handleMarkAsSeen(item); + return true; + } + if (itemId == R.id.refresh && viewModel != null) { + viewModel.refreshChats(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void handleMarkAsSeen(@NonNull final MenuItem item) { + final LiveData> resourceLiveData = viewModel.markAsSeen(); + resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(final Resource resource) { + try { + if (resource == null) return; + final Context context = getContext(); + if (context == null) return; + switch (resource.status) { + case SUCCESS: + Toast.makeText(context, R.string.marked_as_seen, Toast.LENGTH_SHORT).show(); + case LOADING: + item.setEnabled(false); + break; + case ERROR: + item.setEnabled(true); + if (resource.message != null) { + Snackbar.make(context, binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); + return; + } + if (resource.resId != 0) { + Snackbar.make(binding.getRoot(), resource.resId, Snackbar.LENGTH_LONG).show(); + return; + } + break; + } + } finally { + resourceLiveData.removeObserver(this); + } + } + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == FILE_PICKER_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + if (data == null || data.getData() == null) { + Log.w(TAG, "data is null!"); + return; + } + final Context context = getContext(); + if (context == null) { + Log.w(TAG, "conetxt is null!"); + return; + } + final Uri uri = data.getData(); + final String mimeType = Utils.getMimeType(uri, context.getContentResolver()); + if (mimeType != null && mimeType.startsWith("image")) { + navigateToImageEditFragment(uri); + return; + } + handleSentMessage(viewModel.sendUri(uri)); + } + if (requestCode == CAMERA_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + if (data == null || data.getData() == null) { + Log.w(TAG, "data is null!"); + return; + } + final Uri uri = data.getData(); + navigateToImageEditFragment(uri); + } + } + + @Override + public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + final Context context = getContext(); + if (context == null) return; + if (requestCode == AUDIO_RECORD_PERM_REQUEST_CODE) { + if (PermissionUtils.hasAudioRecordPerms(context)) { + Toast.makeText(context, "You can send voice messages now!", Toast.LENGTH_LONG).show(); + return; + } + Toast.makeText(context, "Require RECORD_AUDIO permission", Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onPause() { + if (isRecording) { + binding.recordView.cancelRecording(binding.send); + } + emojiPickerVisible.postValue(false); + kbVisible.postValue(false); + binding.inputHolder.setTranslationY(0); + binding.chats.setTranslationY(0); + binding.emojiPicker.setTranslationY(0); + removeObservers(); + super.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + if (initialSendX != 0) { + binding.send.setX(initialSendX); + } + binding.send.stopScale(); + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedDispatcher.addCallback(onEmojiPickerBackPressedCallback); + setupBackStackResultObserver(); + setObservers(); + // attachPendingRequestsBadge(viewModel.getPendingRequestsCount().getValue()); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + cleanup(); + } + + @Override + public void onDestroy() { + viewModel.deleteThreadIfRequired(); + super.onDestroy(); + } + + @SuppressLint("UnsafeOptInUsageError") + private void cleanup() { + if (prevTitleRunnable != null) { + appExecutors.getMainThread().cancel(prevTitleRunnable); + } + for (int childCount = binding.chats.getChildCount(), i = 0; i < childCount; ++i) { + final RecyclerView.ViewHolder holder = binding.chats.getChildViewHolder(binding.chats.getChildAt(i)); + if (holder == null) continue; + if (holder instanceof DirectItemViewHolder) { + ((DirectItemViewHolder) holder).cleanup(); + } + } + isPendingRequestCountBadgeAttached = false; + if (pendingRequestCountBadgeDrawable != null) { + @SuppressLint("RestrictedApi") final ActionMenuItemView menuItemView = ToolbarUtils + .getActionMenuItemView(fragmentActivity.getToolbar(), R.id.info); + if (menuItemView != null) { + BadgeUtils.detachBadgeDrawable(pendingRequestCountBadgeDrawable, fragmentActivity.getToolbar(), R.id.info); + } + pendingRequestCountBadgeDrawable = null; + } + } + + private void init() { + final Context context = getContext(); + if (context == null) return; + if (getArguments() == null) return; + actionBar = fragmentActivity.getSupportActionBar(); + setupList(); + } + + private void setupList() { + final Context context = getContext(); + if (context == null) return; + binding.chats.setItemViewCacheSize(20); + final LinearLayoutManager layoutManager = new LinearLayoutManager(context); + layoutManager.setReverseLayout(true); + // layoutManager.setStackFromEnd(false); + // binding.messageList.addItemDecoration(new VerticalSpaceItemDecoration(3)); + final RecyclerView.ItemAnimator animator = binding.chats.getItemAnimator(); + if (animator instanceof SimpleItemAnimator) { + final SimpleItemAnimator itemAnimator = (SimpleItemAnimator) animator; + itemAnimator.setSupportsChangeAnimations(false); + } + binding.chats.setLayoutManager(layoutManager); + binding.chats.addOnScrollListener(new RecyclerLazyLoaderAtEdge(layoutManager, true, page -> viewModel.fetchChats())); + final HeaderItemDecoration headerItemDecoration = new HeaderItemDecoration(binding.chats, itemPosition -> { + if (itemOrHeaders == null || itemOrHeaders.isEmpty()) return false; + try { + final DirectItemOrHeader itemOrHeader = itemOrHeaders.get(itemPosition); + return itemOrHeader.isHeader(); + } catch (IndexOutOfBoundsException e) { + return false; + } + }); + binding.chats.addItemDecoration(headerItemDecoration); + } + + private void setObservers() { + if (viewModel == null) return; + threadLiveData = viewModel.getThread(); + // if (threadLiveData == null) { + // final NavController navController = NavHostFragment.findNavController(this); + // navController.navigateUp(); + // return; + // } + pendingLiveData = viewModel.isPending(); + pendingLiveData.observe(getViewLifecycleOwner(), isPending -> { + if (isPending == null) { + hideInput(); + return; + } + if (isPending) { + showPendingOptions(); + return; + } + hidePendingOptions(); + final Integer inputMode = viewModel.getInputMode().getValue(); + if (inputMode != null && inputMode == 1) return; + showInput(); + }); + inputModeLiveData = viewModel.getInputMode(); + inputModeLiveData.observe(getViewLifecycleOwner(), inputMode -> { + final Boolean isPending = viewModel.isPending().getValue(); + if (isPending != null && isPending || inputMode == null) return; + setupInput(inputMode); + if (inputMode == 0) { + setupTouchHelper(); + return; + } + if (inputMode == 1) { + hideInput(); + } + }); + threadTitleLiveData = viewModel.getThreadTitle(); + threadTitleLiveData.observe(getViewLifecycleOwner(), this::setTitle); + fetchingLiveData = viewModel.isFetching(); + fetchingLiveData.observe(getViewLifecycleOwner(), fetchingResource -> { + if (fetchingResource == null) return; + switch (fetchingResource.status) { + case SUCCESS: + case ERROR: + setTitle(viewModel.getThreadTitle().getValue()); + if (fetchingResource.message != null) { + Snackbar.make(binding.getRoot(), fetchingResource.message, Snackbar.LENGTH_LONG).show(); + } + if (fetchingResource.resId != 0) { + Snackbar.make(binding.getRoot(), fetchingResource.resId, Snackbar.LENGTH_LONG).show(); + } + break; + case LOADING: + setTitle(getString(R.string.dms_thread_updating)); + break; + } + }); + // final ItemsAdapterDataMerger itemsAdapterDataMerger = new ItemsAdapterDataMerger(appStateViewModel.getCurrentUser(), viewModel.getThread()); + // itemsAdapterDataMerger.observe(getViewLifecycleOwner(), userThreadPair -> { + // viewModel.setCurrentUser(userThreadPair.first); + // setupItemsAdapter(userThreadPair.first, userThreadPair.second); + // }); + threadLiveData.observe(getViewLifecycleOwner(), this::setupItemsAdapter); + itemsLiveData = viewModel.getItems(); + itemsLiveData.observe(getViewLifecycleOwner(), this::submitItemsToAdapter); + replyToItemLiveData = viewModel.getReplyToItem(); + replyToItemLiveData.observe(getViewLifecycleOwner(), item -> { + if (item == null) { + if (binding.input.length() == 0) { + showExtraInputOption(true); + } + binding.getRoot().post(() -> { + TransitionManager.beginDelayedTransition(binding.getRoot()); + binding.replyBg.setVisibility(View.GONE); + binding.replyInfo.setVisibility(View.GONE); + binding.replyPreviewImage.setVisibility(View.GONE); + binding.replyCancel.setVisibility(View.GONE); + binding.replyPreviewText.setVisibility(View.GONE); + }); + return; + } + showExtraInputOption(false); + binding.getRoot().postDelayed(() -> { + binding.replyBg.setVisibility(View.VISIBLE); + binding.replyInfo.setVisibility(View.VISIBLE); + binding.replyPreviewImage.setVisibility(View.VISIBLE); + binding.replyCancel.setVisibility(View.VISIBLE); + binding.replyPreviewText.setVisibility(View.VISIBLE); + if (item.getUserId() == viewModel.getViewerId()) { + binding.replyInfo.setText(R.string.replying_to_yourself); + } else { + final User user = viewModel.getUser(item.getUserId()); + if (user != null) { + binding.replyInfo.setText(getString(R.string.replying_to_user, user.getFullName())); + } else { + binding.replyInfo.setVisibility(View.GONE); + } + } + final String previewText = getDirectItemPreviewText(item); + binding.replyPreviewText.setText(TextUtils.isEmpty(previewText) ? getString(R.string.message) : previewText); + final String previewImageUrl = getDirectItemPreviewImageUrl(item); + if (TextUtils.isEmpty(previewImageUrl)) { + binding.replyPreviewImage.setVisibility(View.GONE); + } else { + binding.replyPreviewImage.setImageURI(previewImageUrl); + } + binding.replyCancel.setOnClickListener(v -> viewModel.setReplyToItem(null)); + }, 200); + }); + inputLength.observe(getViewLifecycleOwner(), length -> { + if (length == null) return; + final boolean hasReplyToItem = viewModel.getReplyToItem().getValue() != null; + if (hasReplyToItem) { + prevLength = length; + return; + } + if ((prevLength == 0 && length != 0) || (prevLength != 0 && length == 0)) { + showExtraInputOption(length == 0); + } + prevLength = length; + }); + pendingRequestsCountLiveData = viewModel.getPendingRequestsCount(); + pendingRequestsCountLiveData.observe(getViewLifecycleOwner(), this::attachPendingRequestsBadge); + usersLiveData = viewModel.getUsers(); + usersLiveData.observe(getViewLifecycleOwner(), users -> { + if (users == null || users.isEmpty()) return; + final User user = users.get(0); + binding.acceptPendingRequestQuestion.setText(getString(R.string.accept_request_from_user, user.getUsername(), user.getFullName())); + }); + } + + private void setupTouchHelper() { + final Context context = getContext(); + if (context == null) return; + touchHelperCallback = new SwipeAndRestoreItemTouchHelperCallback( + context, + (adapterPosition, viewHolder) -> { + if (itemsAdapter == null) return; + final DirectItemOrHeader directItemOrHeader = itemsAdapter.getList().get(adapterPosition); + if (directItemOrHeader.isHeader()) return; + viewModel.setReplyToItem(directItemOrHeader.item); + } + ); + itemTouchHelper = new ItemTouchHelper(touchHelperCallback); + itemTouchHelper.attachToRecyclerView(binding.chats); + } + + private void removeObservers() { + pendingLiveData.removeObservers(getViewLifecycleOwner()); + inputModeLiveData.removeObservers(getViewLifecycleOwner()); + threadTitleLiveData.removeObservers(getViewLifecycleOwner()); + fetchingLiveData.removeObservers(getViewLifecycleOwner()); + threadLiveData.removeObservers(getViewLifecycleOwner()); + itemsLiveData.removeObservers(getViewLifecycleOwner()); + replyToItemLiveData.removeObservers(getViewLifecycleOwner()); + inputLength.removeObservers(getViewLifecycleOwner()); + pendingRequestsCountLiveData.removeObservers(getViewLifecycleOwner()); + usersLiveData.removeObservers(getViewLifecycleOwner()); + + } + + private void hidePendingOptions() { + binding.acceptPendingRequestQuestion.setVisibility(View.GONE); + binding.decline.setVisibility(View.GONE); + binding.accept.setVisibility(View.GONE); + } + + private void showPendingOptions() { + binding.acceptPendingRequestQuestion.setVisibility(View.VISIBLE); + binding.decline.setVisibility(View.VISIBLE); + binding.accept.setVisibility(View.VISIBLE); + binding.accept.setOnClickListener(v -> { + final LiveData> resourceLiveData = viewModel.acceptRequest(); + handlePendingChangeResource(resourceLiveData, false); + }); + binding.decline.setOnClickListener(v -> { + final LiveData> resourceLiveData = viewModel.declineRequest(); + handlePendingChangeResource(resourceLiveData, true); + }); + } + + private void handlePendingChangeResource(final LiveData> resourceLiveData, final boolean isDecline) { + resourceLiveData.observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + final Resource.Status status = resource.status; + switch (status) { + case SUCCESS: + resourceLiveData.removeObservers(getViewLifecycleOwner()); + if (isDecline) { + removeObservers(); + viewModel.removeThread(); + final NavController navController = NavHostFragment.findNavController(this); + navController.navigateUp(); + return; + } + removeObservers(); + viewModel.moveFromPending(); + setObservers(); + break; + case LOADING: + break; + case ERROR: + if (resource.message != null) { + Snackbar.make(binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); + } + if (resource.resId != 0) { + Snackbar.make(binding.getRoot(), resource.resId, Snackbar.LENGTH_LONG).show(); + } + resourceLiveData.removeObservers(getViewLifecycleOwner()); + break; + } + }); + } + + private void hideInput() { + binding.emojiToggle.setVisibility(View.GONE); + binding.gif.setVisibility(View.GONE); + binding.camera.setVisibility(View.GONE); + binding.gallery.setVisibility(View.GONE); + binding.input.setVisibility(View.GONE); + binding.inputBg.setVisibility(View.GONE); + binding.recordView.setVisibility(View.GONE); + binding.send.setVisibility(View.GONE); + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + } + + private void showInput() { + binding.emojiToggle.setVisibility(View.VISIBLE); + binding.gif.setVisibility(View.VISIBLE); + binding.camera.setVisibility(View.VISIBLE); + binding.gallery.setVisibility(View.VISIBLE); + binding.input.setVisibility(View.VISIBLE); + binding.inputBg.setVisibility(View.VISIBLE); + binding.recordView.setVisibility(View.VISIBLE); + binding.send.setVisibility(View.VISIBLE); + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(binding.chats); + } + } + + @SuppressLint("UnsafeOptInUsageError") + private void attachPendingRequestsBadge(@Nullable final Integer count) { + if (pendingRequestCountBadgeDrawable == null) { + final Context context = getContext(); + if (context == null) return; + pendingRequestCountBadgeDrawable = BadgeDrawable.create(context); + } + if (count == null || count == 0) { + @SuppressLint("RestrictedApi") final ActionMenuItemView menuItemView = ToolbarUtils + .getActionMenuItemView(fragmentActivity.getToolbar(), R.id.info); + if (menuItemView != null) { + BadgeUtils.detachBadgeDrawable(pendingRequestCountBadgeDrawable, fragmentActivity.getToolbar(), R.id.info); + } + isPendingRequestCountBadgeAttached = false; + pendingRequestCountBadgeDrawable.setNumber(0); + return; + } + if (pendingRequestCountBadgeDrawable.getNumber() == count) return; + pendingRequestCountBadgeDrawable.setNumber(count); + if (!isPendingRequestCountBadgeAttached) { + BadgeUtils.attachBadgeDrawable(pendingRequestCountBadgeDrawable, fragmentActivity.getToolbar(), R.id.info); + isPendingRequestCountBadgeAttached = true; + } + } + + private void showExtraInputOption(final boolean show) { + if (show) { + if (!binding.send.isListenForRecord()) { + binding.send.setListenForRecord(true); + startIconAnimation(); + } + binding.gif.setVisibility(View.VISIBLE); + binding.camera.setVisibility(View.VISIBLE); + binding.gallery.setVisibility(View.VISIBLE); + return; + } + if (binding.send.isListenForRecord()) { + binding.send.setListenForRecord(false); + startIconAnimation(); + } + binding.gif.setVisibility(View.GONE); + binding.camera.setVisibility(View.GONE); + binding.gallery.setVisibility(View.GONE); + } + + private String getDirectItemPreviewText(@NonNull final DirectItem item) { + final DirectItemType itemType = item.getItemType(); + if (itemType == null) return ""; + switch (itemType) { + case TEXT: + return item.getText(); + case LINK: + final DirectItemLink link = item.getLink(); + if (link == null) return ""; + return link.getText(); + case MEDIA: { + final Media media = item.getMedia(); + if (media == null) return ""; + return getMediaPreviewTextString(media); + } + case RAVEN_MEDIA: { + final DirectItemVisualMedia visualMedia = item.getVisualMedia(); + if (visualMedia == null) return ""; + final Media media = visualMedia.getMedia(); + if (media == null) return ""; + return getMediaPreviewTextString(media); + } + case VOICE_MEDIA: + return getString(R.string.voice_message); + case MEDIA_SHARE: + return getString(R.string.post); + case REEL_SHARE: + final DirectItemReelShare reelShare = item.getReelShare(); + if (reelShare == null) return ""; + return reelShare.getText(); + } + return ""; + } + + @NonNull + private String getMediaPreviewTextString(@NonNull final Media media) { + final MediaItemType mediaType = media.getType(); + if (mediaType == null) return ""; + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + return getString(R.string.photo); + case MEDIA_TYPE_VIDEO: + return getString(R.string.video); + default: + return ""; + } + } + + @Nullable + private String getDirectItemPreviewImageUrl(@NonNull final DirectItem item) { + final DirectItemType itemType = item.getItemType(); + if (itemType == null) return null; + switch (itemType) { + case TEXT: + case LINK: + case VOICE_MEDIA: + case REEL_SHARE: + return null; + case MEDIA: { + final Media media = item.getMedia(); + return ResponseBodyUtils.getThumbUrl(media); + } + case RAVEN_MEDIA: { + final DirectItemVisualMedia visualMedia = item.getVisualMedia(); + if (visualMedia == null) return null; + final Media media = visualMedia.getMedia(); + return ResponseBodyUtils.getThumbUrl(media); + } + case MEDIA_SHARE: { + final Media media = item.getMediaShare(); + return ResponseBodyUtils.getThumbUrl(media); + } + } + return null; + } + + private void setupBackStackResultObserver() { + final NavController navController = NavHostFragment.findNavController(this); + final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); + if (backStackEntry != null) { + backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); + backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); + } + } + + private void submitItemsToAdapter(final List items) { + binding.chats.post(() -> { + if (autoMarkAsSeen) { + viewModel.markAsSeen(); + return; + } + final DirectThread thread = threadLiveData.getValue(); + if (thread == null) return; + if (markAsSeenMenuItem != null) { + markAsSeenMenuItem.setEnabled(!DMUtils.isRead(thread)); + } + }); + if (itemsAdapter == null) return; + itemsAdapter.submitList(items, () -> { + itemOrHeaders = itemsAdapter.getList(); + binding.chats.post(() -> { + final RecyclerView.LayoutManager layoutManager = binding.chats.getLayoutManager(); + if (layoutManager instanceof LinearLayoutManager) { + final int position = ((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition(); + if (position < 0) return; + if (position == itemsAdapter.getItemCount() - 1) { + viewModel.fetchChats(); + } + } + }); + }); + } + + private void setupItemsAdapter(final DirectThread thread) { + if (thread == null) return; + if (itemsAdapter != null) { + if (itemsAdapter.getThread() == thread) return; + itemsAdapter.setThread(thread); + return; + } + final Resource currentUserResource = appStateViewModel.getCurrentUser(); + if (currentUserResource == null) return; + final User currentUser = currentUserResource.data; + if (currentUser == null) return; + itemsAdapter = new DirectItemsAdapter(currentUser, thread, directItemCallback, directItemLongClickListener); + itemsAdapter.setHasStableIds(true); + itemsAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); + binding.chats.setAdapter(itemsAdapter); + registerDataObserver(); + final List items = viewModel.getItems().getValue(); + if (items != null && itemsAdapter.getItems() != items) { + submitItemsToAdapter(items); + } + } + + private void registerDataObserver() { + itemsAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { + + @Override + public void onItemRangeInserted(final int positionStart, final int itemCount) { + super.onItemRangeInserted(positionStart, itemCount); + final LinearLayoutManager layoutManager = (LinearLayoutManager) binding.chats.getLayoutManager(); + if (layoutManager == null) return; + int firstVisiblePosition = layoutManager.findFirstCompletelyVisibleItemPosition(); + if ((firstVisiblePosition == -1 || firstVisiblePosition == 0) && (positionStart == 0)) { + binding.chats.scrollToPosition(0); + } + } + }); + } + + private void setupInput(@Nullable final Integer inputMode) { + if (inputMode != null && inputMode == 1) return; + final Context context = getContext(); + if (context == null) return; + tooltip.setText(R.string.dms_thread_audio_hint); + setMicToSendIcon(); + binding.recordView.setMinMillis(1000); + binding.recordView.setOnRecordListener(new RecordView.OnRecordListener() { + @Override + public void onStart() { + isRecording = true; + binding.input.setHint(null); + binding.gif.setVisibility(View.GONE); + binding.camera.setVisibility(View.GONE); + binding.gallery.setVisibility(View.GONE); + if (PermissionUtils.hasAudioRecordPerms(context)) { + viewModel.startRecording(); + return; + } + PermissionUtils.requestAudioRecordPerms(DirectMessageThreadFragment.this, AUDIO_RECORD_PERM_REQUEST_CODE); + } + + @Override + public void onCancel() { + Log.d(TAG, "onCancel"); + // binding.input.setHint("Message"); + viewModel.stopRecording(true); + isRecording = false; + } + + @Override + public void onFinish(final long recordTime) { + Log.d(TAG, "onFinish"); + binding.input.setHint("Message"); + binding.gif.setVisibility(View.VISIBLE); + binding.camera.setVisibility(View.VISIBLE); + binding.gallery.setVisibility(View.VISIBLE); + viewModel.stopRecording(false); + isRecording = false; + } + + @Override + public void onLessThanMin() { + Log.d(TAG, "onLessThanMin"); + binding.input.setHint("Message"); + if (PermissionUtils.hasAudioRecordPerms(context)) { + tooltip.show(binding.send); + } + binding.gif.setVisibility(View.VISIBLE); + binding.camera.setVisibility(View.VISIBLE); + binding.gallery.setVisibility(View.VISIBLE); + viewModel.stopRecording(true); + isRecording = false; + } + }); + binding.recordView.setOnBasketAnimationEndListener(() -> { + binding.input.setHint(R.string.dms_thread_message_hint); + binding.gif.setVisibility(View.VISIBLE); + binding.camera.setVisibility(View.VISIBLE); + binding.gallery.setVisibility(View.VISIBLE); + }); + binding.input.addTextChangedListener(new TextWatcherAdapter() { + // int prevLength = 0; + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + final int length = s.length(); + inputLength.postValue(length); + } + }); + binding.send.setOnRecordClickListener(v -> { + final Editable text = binding.input.getText(); + if (TextUtils.isEmpty(text)) return; + final LiveData> resourceLiveData = viewModel.sendText(text.toString()); + resourceLiveData.observe(getViewLifecycleOwner(), resource -> handleSentMessage(resourceLiveData)); + binding.input.setText(""); + viewModel.setReplyToItem(null); + }); + binding.send.setOnRecordLongClickListener(v -> { + Log.d(TAG, "setOnRecordLongClickListener"); + return true; + }); + binding.input.setOnFocusChangeListener((v, hasFocus) -> { + if (!hasFocus) return; + final Boolean emojiPickerVisibleValue = emojiPickerVisible.getValue(); + if (emojiPickerVisibleValue == null || !emojiPickerVisibleValue) return; + inputHolderAnimationCallback.setShouldTranslate(false); + chatsAnimationCallback.setShouldTranslate(false); + emojiPickerAnimationCallback.setShouldTranslate(false); + }); + setupInsetsCallback(); + setupEmojiPicker(); + binding.gallery.setOnClickListener(v -> { + final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.setType("*/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{ + "image/*", + "video/mp4" + }); + startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); + }); + binding.gif.setOnClickListener(v -> { + final GifPickerBottomDialogFragment gifPicker = GifPickerBottomDialogFragment.newInstance(); + gifPicker.setOnSelectListener(giphyGif -> { + gifPicker.dismiss(); + if (giphyGif == null) return; + handleSentMessage(viewModel.sendAnimatedMedia(giphyGif)); + }); + gifPicker.show(getChildFragmentManager(), "GifPicker"); + }); + binding.camera.setOnClickListener(v -> { + final Intent intent = new Intent(context, CameraActivity.class); + startActivityForResult(intent, CAMERA_REQUEST_CODE); + }); + } + + private void setupInsetsCallback() { + inputHolderAnimationCallback = new TranslateDeferringInsetsAnimationCallback( + binding.inputHolder, + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime(), + WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE + ); + ViewCompat.setWindowInsetsAnimationCallback(binding.inputHolder, inputHolderAnimationCallback); + chatsAnimationCallback = new TranslateDeferringInsetsAnimationCallback( + binding.chats, + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime() + ); + ViewCompat.setWindowInsetsAnimationCallback(binding.chats, chatsAnimationCallback); + emojiPickerAnimationCallback = new EmojiPickerInsetsAnimationCallback( + binding.emojiPicker, + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime() + ); + emojiPickerAnimationCallback.setKbVisibilityListener(this::onKbVisibilityChange); + ViewCompat.setWindowInsetsAnimationCallback(binding.emojiPicker, emojiPickerAnimationCallback); + ViewCompat.setWindowInsetsAnimationCallback( + binding.input, + new ControlFocusInsetsAnimationCallback(binding.input) + ); + final SimpleImeAnimationController imeAnimController = root.getImeAnimController(); + if (imeAnimController != null) { + imeAnimController.setAnimationControlListener(new WindowInsetsAnimationControlListenerCompat() { + @Override + public void onReady(@NonNull final WindowInsetsAnimationControllerCompat controller, final int types) {} + + @Override + public void onFinished(@NonNull final WindowInsetsAnimationControllerCompat controller) { + checkKbVisibility(); + } + + @Override + public void onCancelled(@Nullable final WindowInsetsAnimationControllerCompat controller) { + checkKbVisibility(); + } + + private void checkKbVisibility() { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(binding.getRoot()); + final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + onKbVisibilityChange(visible); + } + }); + } + } + + private void onKbVisibilityChange(final boolean kbVisible) { + this.kbVisible.postValue(kbVisible); + if (wasToggled) { + emojiPickerVisible.postValue(!kbVisible); + wasToggled = false; + return; + } + final Boolean emojiPickerVisibleValue = emojiPickerVisible.getValue(); + if (kbVisible && emojiPickerVisibleValue != null && emojiPickerVisibleValue) { + emojiPickerVisible.postValue(false); + return; + } + if (!kbVisible) { + emojiPickerVisible.postValue(false); + } + } + + private void startIconAnimation() { + final Drawable icon = binding.send.getIcon(); + if (icon instanceof Animatable) { + final Animatable animatable = (Animatable) icon; + if (animatable.isRunning()) { + animatable.stop(); + } + animatable.start(); + } + } + + private void navigateToImageEditFragment(final String path) { + navigateToImageEditFragment(Uri.fromFile(new File(path))); + } + + private void navigateToImageEditFragment(final Uri uri) { + try { + final NavDirections navDirections = DirectMessageThreadFragmentDirections.actionToImageEdit(uri); + NavHostFragment.findNavController(this).navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "navigateToImageEditFragment: ", e); + } + } + + private void handleSentMessage(final LiveData> resourceLiveData) { + final Resource resource = resourceLiveData.getValue(); + if (resource == null) return; + final Resource.Status status = resource.status; + switch (status) { + case SUCCESS: + resourceLiveData.removeObservers(getViewLifecycleOwner()); + break; + case LOADING: + break; + case ERROR: + if (resource.message != null) { + Snackbar.make(binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); + } + if (resource.resId != 0) { + Snackbar.make(binding.getRoot(), resource.resId, Snackbar.LENGTH_LONG).show(); + } + resourceLiveData.removeObservers(getViewLifecycleOwner()); + break; + } + } + + private void setupEmojiPicker() { + root.post(() -> binding.emojiPicker.init( + root, + (view, emoji) -> { + final KeyNotifyingEmojiEditText input = binding.input; + final int start = input.getSelectionStart(); + final int end = input.getSelectionEnd(); + if (start < 0) { + input.append(emoji.getUnicode()); + return; + } + input.getText().replace( + Math.min(start, end), + Math.max(start, end), + emoji.getUnicode(), + 0, + emoji.getUnicode().length() + ); + }, + () -> binding.input.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + )); + binding.emojiToggle.setOnClickListener(v -> { + Boolean isEmojiPickerVisible = emojiPickerVisible.getValue(); + if (isEmojiPickerVisible == null) isEmojiPickerVisible = false; + Boolean isKbVisible = kbVisible.getValue(); + if (isKbVisible == null) isKbVisible = false; + wasToggled = isEmojiPickerVisible || isKbVisible; + + if (isEmojiPickerVisible) { + if (hasKbOpenedOnce && binding.emojiPicker.getTranslationY() != 0) { + inputHolderAnimationCallback.setShouldTranslate(false); + chatsAnimationCallback.setShouldTranslate(false); + emojiPickerAnimationCallback.setShouldTranslate(false); + } + // trigger ime. + // Since the kb visibility listener will toggle the emojiPickerVisible live data, we do not explicitly toggle it here + showKeyboard(); + return; + } + + if (isKbVisible) { + // hide the keyboard, but don't translate the views + // Since the kb visibility listener will toggle the emojiPickerVisible live data, we do not explicitly toggle it here + inputHolderAnimationCallback.setShouldTranslate(false); + chatsAnimationCallback.setShouldTranslate(false); + emojiPickerAnimationCallback.setShouldTranslate(false); + hideKeyboard(); + } + emojiPickerVisible.postValue(true); + }); + final LiveData> emojiKbVisibilityLD = Utils.zipLiveData(emojiPickerVisible, kbVisible); + emojiKbVisibilityLD.observe(getViewLifecycleOwner(), pair -> { + Boolean isEmojiPickerVisible = pair.first; + Boolean isKbVisible = pair.second; + if (isEmojiPickerVisible == null) isEmojiPickerVisible = false; + if (isKbVisible == null) isKbVisible = false; + root.setScrollImeOffScreenWhenVisible(!isEmojiPickerVisible); + root.setScrollImeOnScreenWhenNotVisible(!isEmojiPickerVisible); + onEmojiPickerBackPressedCallback.setEnabled(isEmojiPickerVisible && !isKbVisible); + if (isEmojiPickerVisible && !isKbVisible) { + animatePan(binding.emojiPicker.getMeasuredHeight(), unused -> { + binding.emojiPicker.setAlpha(1); + binding.emojiToggle.setIconResource(R.drawable.ic_keyboard_24); + return null; + }, null); + return; + } + if (!isEmojiPickerVisible && !isKbVisible) { + animatePan(0, null, unused -> { + binding.emojiPicker.setAlpha(0); + binding.emojiToggle.setIconResource(R.drawable.ic_face_24); + return null; + }); + return; + } + // isKbVisible will always be true going forward + hasKbOpenedOnce = true; + if (!isEmojiPickerVisible) { + binding.emojiToggle.setIconResource(R.drawable.ic_face_24); + binding.emojiPicker.setAlpha(0); + return; + } + binding.emojiPicker.setAlpha(1); + }); + } + + public void showKeyboard() { + final Context context = getContext(); + if (context == null) return; + final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + if (!binding.input.isFocused()) { + binding.input.requestFocus(); + } + final boolean shown = imm.showSoftInput(binding.input, InputMethodManager.SHOW_IMPLICIT); + if (!shown) { + Log.e(TAG, "showKeyboard: System did not display the keyboard"); + } + } + + public void hideKeyboard() { + final Context context = getContext(); + if (context == null) return; + final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.hideSoftInputFromWindow(binding.input.getWindowToken(), InputMethodManager.RESULT_UNCHANGED_SHOWN); + } + + private void setSendToMicIcon() { + final Context context = getContext(); + if (context == null) return; + final Drawable sendToMicDrawable = Utils.getAnimatableDrawable(context, R.drawable.avd_send_to_mic_anim); + if (sendToMicDrawable instanceof Animatable) { + AnimatedVectorDrawableCompat.registerAnimationCallback(sendToMicDrawable, sendToMicAnimationCallback); + } + binding.send.setIcon(sendToMicDrawable); + } + + private void setMicToSendIcon() { + final Context context = getContext(); + if (context == null) return; + final Drawable micToSendDrawable = Utils.getAnimatableDrawable(context, R.drawable.avd_mic_to_send_anim); + if (micToSendDrawable instanceof Animatable) { + AnimatedVectorDrawableCompat.registerAnimationCallback(micToSendDrawable, micToSendAnimationCallback); + } + binding.send.setIcon(micToSendDrawable); + } + + private void setTitle(final String title) { + if (actionBar == null) return; + if (prevTitleRunnable != null) { + appExecutors.getMainThread().cancel(prevTitleRunnable); + } + prevTitleRunnable = () -> actionBar.setTitle(title); + // set title delayed to avoid title blink if fetch is fast + appExecutors.getMainThread().execute(prevTitleRunnable, 1000); + } + + private void downloadItem(final DirectItem item) { + final Context context = getContext(); + if (context == null) return; + final DirectItemType itemType = item.getItemType(); + if (itemType == null) return; + //noinspection SwitchStatementWithTooFewBranches + switch (itemType) { + case VOICE_MEDIA: + downloadItem(context, item.getVoiceMedia() == null ? null : item.getVoiceMedia().getMedia()); + break; + default: + break; + } + } + + // currently ONLY for voice + private void downloadItem(@NonNull final Context context, final Media media) { + if (media == null) { + Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); + return; + } + DownloadUtils.download(context, media); + Toast.makeText(context, R.string.downloader_downloading_media, Toast.LENGTH_SHORT).show(); + } + + // Sets the translationY of views to height with animation + private void animatePan(final int height, + @Nullable final Function onAnimationStart, + @Nullable final Function onAnimationEnd) { + if (animatorSet != null && animatorSet.isStarted()) { + animatorSet.cancel(); + } + final ImmutableList.Builder builder = ImmutableList.builder(); + builder.add( + ObjectAnimator.ofFloat(binding.chats, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.inputHolder, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.emojiPicker, TRANSLATION_Y, -height) + ); + // if (headerItemDecoration != null && headerItemDecoration.getCurrentHeader() != null) { + // builder.add(ObjectAnimator.ofFloat(headerItemDecoration.getCurrentHeader(), TRANSLATION_Y, height)); + // } + animatorSet = new AnimatorSet(); + animatorSet.playTogether(builder.build()); + animatorSet.setDuration(200); + animatorSet.setInterpolator(CubicBezierInterpolator.EASE_IN); + animatorSet.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(final Animator animation) { + super.onAnimationStart(animation); + if (onAnimationStart != null) { + onAnimationStart.apply(null); + } + } + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + animatorSet = null; + if (onAnimationEnd != null) { + onAnimationEnd.apply(null); + } + } + }); + animatorSet.start(); + } + + private void showReactionsDialog(final DirectItem item) { + final LiveData> users = viewModel.getUsers(); + final LiveData> leftUsers = viewModel.getLeftUsers(); + final ArrayList allUsers = new ArrayList<>(); + allUsers.add(viewModel.getCurrentUser()); + if (users.getValue() != null) { + allUsers.addAll(users.getValue()); + } + if (leftUsers.getValue() != null) { + allUsers.addAll(leftUsers.getValue()); + } + final String itemId = item.getItemId(); + if (itemId == null) return; + final DirectItemReactions reactions = item.getReactions(); + if (reactions == null) return; + reactionDialogFragment = DirectItemReactionDialogFragment.newInstance( + viewModel.getViewerId(), + allUsers, + itemId, + reactions + ); + reactionDialogFragment.show(getChildFragmentManager(), "reactions_dialog"); + } + + @Override + public void onReactionClick(final String itemId, final DirectItemEmojiReaction reaction) { + if (reactionDialogFragment != null) { + reactionDialogFragment.dismiss(); + } + if (itemId == null || reaction == null) return; + if (reaction.getSenderId() == viewModel.getViewerId()) { + final LiveData> resourceLiveData = viewModel.sendDeleteReaction(itemId); + resourceLiveData.observe(getViewLifecycleOwner(), directItemResource -> handleSentMessage(resourceLiveData)); + return; + } + // navigate to user + final User user = viewModel.getUser(reaction.getSenderId()); + if (user == null) return; + navigateToUser(user.getUsername()); + } + + private void navigateToUser(@NonNull final String username) { + try { + final NavDirections direction = DirectMessageThreadFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(direction); + } catch (Exception e) { + Log.e(TAG, "navigateToUser: ", e); + } + } + + @Override + public void onClick(final View view, final Emoji emoji) { + if (addReactionItem == null || emoji == null) return; + final LiveData> resourceLiveData = viewModel.sendReaction(addReactionItem, emoji); + resourceLiveData.observe(getViewLifecycleOwner(), directItemResource -> handleSentMessage(resourceLiveData)); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectPendingInboxFragment.kt b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectPendingInboxFragment.kt new file mode 100644 index 0000000..f321a55 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectPendingInboxFragment.kt @@ -0,0 +1,148 @@ +package awais.instagrabber.fragments.directmessages + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import awais.instagrabber.activities.MainActivity +import awais.instagrabber.adapters.DirectMessageInboxAdapter +import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge +import awais.instagrabber.databinding.FragmentDirectPendingInboxBinding +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.directmessages.DirectInbox +import awais.instagrabber.repositories.responses.directmessages.DirectThread +import awais.instagrabber.viewmodels.DirectPendingInboxViewModel +import com.google.android.material.snackbar.Snackbar + +class DirectPendingInboxFragment : Fragment(), OnRefreshListener { + private val viewModel: DirectPendingInboxViewModel by activityViewModels() + + private lateinit var root: CoordinatorLayout + private lateinit var lazyLoader: RecyclerLazyLoaderAtEdge + private lateinit var binding: FragmentDirectPendingInboxBinding + private lateinit var fragmentActivity: MainActivity + private lateinit var inboxAdapter: DirectMessageInboxAdapter + + private var shouldRefresh = true + private var scrollToTop = false + private var navigating = false + private var threadsObserver: Observer>? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + fragmentActivity = requireActivity() as MainActivity + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + if (this::root.isInitialized) { + shouldRefresh = false + return root + } + binding = FragmentDirectPendingInboxBinding.inflate(inflater, container, false) + root = binding.root + return root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (!shouldRefresh) return + init() + } + + override fun onRefresh() { + lazyLoader.resetState() + scrollToTop = true + viewModel.refresh() + } + + override fun onResume() { + super.onResume() + setupObservers() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + init() + } + + override fun onDestroy() { + super.onDestroy() + removeViewModelObservers() + viewModel.onDestroy() + } + + private fun setupObservers() { + removeViewModelObservers() + threadsObserver = Observer { list: List? -> + if (!this::inboxAdapter.isInitialized) return@Observer + if (binding.swipeRefreshLayout.visibility == View.GONE) { + binding.swipeRefreshLayout.visibility = View.VISIBLE + binding.empty.visibility = View.GONE + } + inboxAdapter.submitList(list ?: emptyList()) { + if (!scrollToTop) return@submitList + binding.pendingInboxList.smoothScrollToPosition(0) + scrollToTop = false + } + if (list == null || list.isEmpty()) { + binding.swipeRefreshLayout.visibility = View.GONE + binding.empty.visibility = View.VISIBLE + } + } + threadsObserver?.let { viewModel.threads.observe(fragmentActivity, it) } + viewModel.inbox.observe(viewLifecycleOwner, { inboxResource: Resource? -> + if (inboxResource == null) return@observe + when (inboxResource.status) { + Resource.Status.SUCCESS -> binding.swipeRefreshLayout.isRefreshing = false + Resource.Status.ERROR -> { + if (inboxResource.message != null) { + Snackbar.make(binding.root, inboxResource.message, Snackbar.LENGTH_LONG).show() + } + binding.swipeRefreshLayout.isRefreshing = false + } + Resource.Status.LOADING -> binding.swipeRefreshLayout.isRefreshing = true + } + }) + } + + private fun removeViewModelObservers() { + threadsObserver?.let { viewModel.threads.removeObserver(it) } + } + + private fun init() { + val context = context ?: return + setupObservers() + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.pendingInboxList.setHasFixedSize(true) + binding.pendingInboxList.setItemViewCacheSize(20) + val layoutManager = LinearLayoutManager(context) + binding.pendingInboxList.layoutManager = layoutManager + inboxAdapter = DirectMessageInboxAdapter { thread -> + if (navigating) return@DirectMessageInboxAdapter + val threadId = thread.threadId ?: return@DirectMessageInboxAdapter + val threadTitle = thread.threadTitle ?: return@DirectMessageInboxAdapter + navigating = true + if (isAdded) { + val directions = DirectPendingInboxFragmentDirections.actionToThread(threadId, threadTitle) + directions.pending = true + NavHostFragment.findNavController(this).navigate(directions) + } + navigating = false + } + inboxAdapter.setHasStableIds(true) + binding.pendingInboxList.adapter = inboxAdapter + lazyLoader = RecyclerLazyLoaderAtEdge(layoutManager) { viewModel.fetchInbox() } + binding.pendingInboxList.addOnScrollListener(lazyLoader) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/FiltersFragment.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/FiltersFragment.java new file mode 100644 index 0000000..4c83b00 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/FiltersFragment.java @@ -0,0 +1,529 @@ +package awais.instagrabber.fragments.imageedit; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.constraintlayout.widget.Barrier; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; + +import com.google.android.material.slider.Slider; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import awais.instagrabber.adapters.FiltersAdapter; +import awais.instagrabber.databinding.FragmentFiltersBinding; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper.FilterType; +import awais.instagrabber.fragments.imageedit.filters.filters.Filter; +import awais.instagrabber.fragments.imageedit.filters.filters.FilterFactory; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.BitmapUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.SerializablePair; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.FiltersFragmentViewModel; +import awais.instagrabber.viewmodels.ImageEditViewModel; +import jp.co.cyberagent.android.gpuimage.GPUImage; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; +import kotlinx.coroutines.Dispatchers; + +public class FiltersFragment extends Fragment { + private static final String TAG = FiltersFragment.class.getSimpleName(); + + private static final String ARGS_SOURCE_URI = "source_uri"; + private static final String ARGS_DEST_URI = "dest_uri"; + private static final String ARGS_TUNING_FILTERS = "tuning_filters"; + private static final String ARGS_FILTER = "filter"; + private static final String ARGS_TAB = "tab"; + + private final Map> tuningFilters = new HashMap<>(); + private final Map, Integer> propertySliderIdMap = new HashMap<>(); + + private GPUImageFilterGroup filterGroup; + private Filter appliedFilter; + private FragmentFiltersBinding binding; + private AppExecutors appExecutors; + private Uri sourceUri; + private Uri destUri; + private FiltersFragmentViewModel viewModel; + private boolean isFilterGroupSet = false; + private FilterCallback callback; + private FiltersAdapter filtersAdapter; + private HashMap> initialTuningFiltersValues; + private SerializablePair> initialFilter; + + @NonNull + public static FiltersFragment newInstance(@NonNull final Uri sourceUri, + @NonNull final Uri destUri, + @NonNull final ImageEditViewModel.Tab tab) { + return newInstance(sourceUri, destUri, null, null, tab); + } + + @NonNull + public static FiltersFragment newInstance(@NonNull final Uri sourceUri, + @NonNull final Uri destUri, + final HashMap> appliedTuningFilters, + final SerializablePair> appliedFilter, + @NonNull final ImageEditViewModel.Tab tab) { + final Bundle args = new Bundle(); + args.putParcelable(ARGS_SOURCE_URI, sourceUri); + args.putParcelable(ARGS_DEST_URI, destUri); + if (appliedTuningFilters != null) { + args.putSerializable(ARGS_TUNING_FILTERS, appliedTuningFilters); + } + if (appliedFilter != null) { + args.putSerializable(ARGS_FILTER, appliedFilter); + } + args.putString(ARGS_TAB, tab.name()); + final FiltersFragment fragment = new FiltersFragment(); + fragment.setArguments(args); + return fragment; + } + + public FiltersFragment() { + filterGroup = new GPUImageFilterGroup(); + filterGroup.addFilter(new GPUImageFilter()); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + appExecutors = AppExecutors.INSTANCE; + viewModel = new ViewModelProvider(this).get(FiltersFragmentViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = FragmentFiltersBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(savedInstanceState); + } + + @Override + public void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + final ImageEditViewModel.Tab tab = viewModel.getCurrentTab().getValue(); + if (tab != null) { + outState.putString(ARGS_TAB, tab.name()); + } + } + + @Override + public void onPause() { + super.onPause(); + // binding.preview.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + // binding.preview.onResume(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + for (final GPUImageFilter filter : filterGroup.getFilters()) { + filter.destroy(); + } + filterGroup.getFilters().clear(); + filterGroup.destroy(); + } + + private void init(final Bundle savedInstanceState) { + setupObservers(); + final Bundle arguments = getArguments(); + if (arguments == null) return; + final Parcelable uriParcelable = arguments.getParcelable(ARGS_SOURCE_URI); + if (!(uriParcelable instanceof Uri)) return; + sourceUri = (Uri) uriParcelable; + final Parcelable destUriParcelable = arguments.getParcelable(ARGS_DEST_URI); + if (!(destUriParcelable instanceof Uri)) return; + destUri = (Uri) destUriParcelable; + final Serializable tuningFiltersSerializable = arguments.getSerializable(ARGS_TUNING_FILTERS); + if (tuningFiltersSerializable instanceof HashMap) { + try { + //noinspection unchecked + initialTuningFiltersValues = (HashMap>) tuningFiltersSerializable; + } catch (Exception e) { + Log.e(TAG, "init: ", e); + } + } + final Serializable filterSerializable = arguments.getSerializable(ARGS_FILTER); + if (filterSerializable instanceof SerializablePair) { + try { + //noinspection unchecked + initialFilter = (SerializablePair>) filterSerializable; + } catch (Exception e) { + Log.e(TAG, "init: ", e); + } + } + final Context context = getContext(); + if (context == null) return; + binding.preview.setScaleType(GPUImage.ScaleType.CENTER_INSIDE); + appExecutors.getTasksThread().execute(() -> { + binding.preview.setImage(sourceUri); + setPreviewBounds(); + }); + setCurrentTab(ImageEditViewModel.Tab.valueOf(savedInstanceState != null && savedInstanceState.containsKey(ARGS_TAB) + ? savedInstanceState.getString(ARGS_TAB) + : arguments.getString(ARGS_TAB))); + binding.cancel.setOnClickListener(v -> { + if (callback == null) return; + callback.onCancel(); + }); + binding.reset.setOnClickListener(v -> { + final ImageEditViewModel.Tab tab = viewModel.getCurrentTab().getValue(); + if (tab == ImageEditViewModel.Tab.TUNE) { + final Collection> filters = tuningFilters.values(); + for (final Filter filter : filters) { + if (filter == null) continue; + filter.reset(); + } + resetSliders(); + } + if (tab == ImageEditViewModel.Tab.FILTERS) { + final List groupFilters = filterGroup.getFilters(); + if (appliedFilter != null) { + groupFilters.remove(appliedFilter.getInstance()); + appliedFilter = null; + } + if (filtersAdapter != null) { + filtersAdapter.setSelected(0); + } + binding.preview.post(() -> binding.preview.setFilter(filterGroup = new GPUImageFilterGroup(groupFilters))); + } + }); + binding.apply.setOnClickListener(v -> { + if (callback == null) return; + final List> appliedTunings = getAppliedTunings(); + appExecutors.getTasksThread().submit(() -> { + final Bitmap bitmap = binding.preview.getGPUImage().getBitmapWithFilterApplied(); + try { + BitmapUtils.convertToJpegAndSaveToUri(context, bitmap, destUri); + callback.onApply(destUri, appliedTunings, appliedFilter); + } catch (Exception e) { + Log.e(TAG, "init: ", e); + } + }); + }); + } + + @NonNull + private List> getAppliedTunings() { + return tuningFilters + .values() + .stream() + .filter(Objects::nonNull) + .filter(filter -> { + final Map> propertyMap = filter.getProperties(); + if (propertyMap == null) return false; + final Collection> properties = propertyMap.values(); + return properties.stream() + .noneMatch(property -> { + final Object value = property.getValue(); + if (value == null) { + return false; + } + return value.equals(property.getDefaultValue()); + }); + }) + .collect(Collectors.toList()); + } + + private void resetSliders() { + final Set, Integer>> entries = propertySliderIdMap.entrySet(); + for (final Map.Entry, Integer> entry : entries) { + final Property property = entry.getKey(); + final Integer viewId = entry.getValue(); + final Slider slider = (Slider) binding.getRoot().findViewById(viewId); + if (slider == null) continue; + final Object defaultValue = property.getDefaultValue(); + if (!(defaultValue instanceof Float)) continue; + slider.setValue((float) defaultValue); + } + } + + private void setPreviewBounds() { + InputStream inputStream = null; + try { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + final Context context = getContext(); + if (context == null) return; + inputStream = context.getContentResolver().openInputStream(sourceUri); + BitmapFactory.decodeStream(inputStream, null, options); + final float ratio = (float) options.outWidth / options.outHeight; + appExecutors.getMainThread().execute(() -> { + final ViewGroup.LayoutParams previewLayoutParams = binding.preview.getLayoutParams(); + if (options.outHeight > options.outWidth) { + previewLayoutParams.width = (int) (binding.preview.getHeight() * ratio); + } else { + previewLayoutParams.height = (int) (binding.preview.getWidth() / ratio); + } + binding.preview.setRatio(ratio); + binding.preview.requestLayout(); + }); + } catch (FileNotFoundException e) { + Log.e(TAG, "setPreviewBounds: ", e); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException ignored) {} + } + } + } + + private void setupObservers() { + viewModel.isLoading().observe(getViewLifecycleOwner(), loading -> { + + }); + viewModel.getCurrentTab().observe(getViewLifecycleOwner(), tab -> { + switch (tab) { + case TUNE: + setupTuning(); + break; + case FILTERS: + setupFilters(); + break; + default: + break; + } + }); + } + + private void setupTuning() { + initTuningControls(); + binding.filters.setVisibility(View.GONE); + binding.tuneControlsWrapper.setVisibility(View.VISIBLE); + } + + private void initTuningControls() { + final Context context = getContext(); + if (context == null) return; + final ConstraintLayout controlsParent = new ConstraintLayout(context); + controlsParent.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + final Barrier sliderBarrier = new Barrier(context); + sliderBarrier.setId(Barrier.generateViewId()); + sliderBarrier.setType(Barrier.START); + controlsParent.addView(sliderBarrier); + binding.tuneControlsWrapper.addView(controlsParent); + final int labelPadding = Utils.convertDpToPx(8); + final List> tuneFilters = FiltersHelper.getTuneFilters(); + Slider previousSlider = null; + // Need to iterate backwards + for (int i = tuneFilters.size() - 1; i >= 0; i--) { + final Filter tuneFilter = tuneFilters.get(i); + if (tuneFilter.getProperties() == null || tuneFilter.getProperties().isEmpty() || tuneFilter.getProperties().size() > 1) continue; + final int propKey = tuneFilter.getProperties().keySet().iterator().next(); + final Property property = tuneFilter.getProperties().values().iterator().next(); + if (!(property instanceof FloatProperty)) continue; + final GPUImageFilter filterInstance = tuneFilter.getInstance(); + tuningFilters.put(tuneFilter.getType(), tuneFilter); + filterGroup.addFilter(filterInstance); + + final FloatProperty floatProperty = (FloatProperty) property; + final Slider slider = new Slider(context); + final int viewId = Slider.generateViewId(); + slider.setId(viewId); + propertySliderIdMap.put(floatProperty, viewId); + + final ConstraintLayout.LayoutParams sliderLayoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + ConstraintLayout.LayoutParams.WRAP_CONTENT); + + sliderLayoutParams.startToEnd = sliderBarrier.getId(); + sliderLayoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; + if (previousSlider == null) { + sliderLayoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; + } else { + sliderLayoutParams.bottomToTop = previousSlider.getId(); + final ConstraintLayout.LayoutParams prevSliderLayoutParams = (ConstraintLayout.LayoutParams) previousSlider.getLayoutParams(); + prevSliderLayoutParams.topToBottom = slider.getId(); + } + if (i == 0) { + sliderLayoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; + } + slider.setLayoutParams(sliderLayoutParams); + slider.setValueFrom(floatProperty.getMinValue()); + slider.setValueTo(floatProperty.getMaxValue()); + float defaultValue = floatProperty.getDefaultValue(); + if (initialTuningFiltersValues != null && initialTuningFiltersValues.containsKey(tuneFilter.getType())) { + final Map valueMap = initialTuningFiltersValues.get(tuneFilter.getType()); + if (valueMap != null) { + final Object value = valueMap.get(propKey); + if (value instanceof Float) { + defaultValue = (float) value; + tuneFilter.adjust(propKey, value); + } + } + } + slider.setValue(defaultValue); + slider.addOnChangeListener((slider1, value, fromUser) -> { + final Filter filter = tuningFilters.get(tuneFilter.getType()); + if (filter != null) { + tuneFilter.adjust(propKey, value); + } + binding.preview.post(() -> binding.preview.requestRender()); + }); + + final AppCompatTextView label = new AppCompatTextView(context); + label.setId(AppCompatTextView.generateViewId()); + final ConstraintLayout.LayoutParams labelLayoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.WRAP_CONTENT, + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT); + labelLayoutParams.topToTop = slider.getId(); + labelLayoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; + labelLayoutParams.endToStart = sliderBarrier.getId(); + labelLayoutParams.bottomToBottom = slider.getId(); + labelLayoutParams.horizontalBias = 1; + label.setLayoutParams(labelLayoutParams); + label.setGravity(Gravity.CENTER); + label.setPadding(labelPadding, labelPadding, labelPadding, labelPadding); + label.setText(tuneFilter.getLabel()); + + controlsParent.addView(label); + controlsParent.addView(slider); + + previousSlider = slider; + } + addInitialFilter(); + if (!isFilterGroupSet) { + isFilterGroupSet = true; + binding.preview.post(() -> binding.preview.setFilter(filterGroup)); + } + } + + private void addInitialFilter() { + if (initialFilter == null) return; + final Filter instance = FilterFactory.getInstance(initialFilter.first); + if (instance == null) return; + addFilterToGroup(instance, initialFilter.second); + appliedFilter = instance; + } + + private void setupFilters() { + final Context context = getContext(); + if (context == null) return; + addTuneFilters(); + binding.filters.setVisibility(View.VISIBLE); + final RecyclerView.ItemAnimator animator = binding.filters.getItemAnimator(); + if (animator instanceof SimpleItemAnimator) { + final SimpleItemAnimator itemAnimator = (SimpleItemAnimator) animator; + itemAnimator.setSupportsChangeAnimations(false); + } + binding.tuneControlsWrapper.setVisibility(View.GONE); + binding.filters.setLayoutManager(new LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)); + final FiltersAdapter.OnFilterClickListener onFilterClickListener = (position, filter) -> { + if (appliedFilter != null && appliedFilter.equals(filter)) return; + final List filters = filterGroup.getFilters(); + if (appliedFilter != null) { + // remove applied filter from current filter list + filters.remove(appliedFilter.getInstance()); + } + // add the new filter + filters.add(filter.getInstance()); + filterGroup = new GPUImageFilterGroup(filters); + binding.preview.post(() -> binding.preview.setFilter(filterGroup)); + filtersAdapter.setSelected(position); + appliedFilter = filter; + }; + BitmapUtils.getThumbnail( + context, + sourceUri, + CoroutineUtilsKt.getContinuation((bitmapResult, throwable) -> appExecutors.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "setupFilters: ", throwable); + return; + } + if (bitmapResult == null || bitmapResult.getBitmap() == null) { + return; + } + filtersAdapter = new FiltersAdapter( + tuningFilters.values() + .stream() + .map(Filter::getInstance) + .collect(Collectors.toList()), + sourceUri.toString(), + bitmapResult.getBitmap(), + onFilterClickListener + ); + binding.filters.setAdapter(filtersAdapter); + filtersAdapter.submitList(FiltersHelper.getFilters(), () -> { + if (appliedFilter == null) return; + filtersAdapter.setSelectedFilter(appliedFilter.getInstance()); + }); + }), Dispatchers.getIO()) + ); + addInitialFilter(); + binding.preview.setFilter(filterGroup); + } + + private void addTuneFilters() { + if (initialTuningFiltersValues == null) return; + final List> tuneFilters = FiltersHelper.getTuneFilters(); + for (final Filter tuneFilter : tuneFilters) { + if (!initialTuningFiltersValues.containsKey(tuneFilter.getType())) continue; + addFilterToGroup(tuneFilter, initialTuningFiltersValues.get(tuneFilter.getType())); + } + } + + private void addFilterToGroup(@NonNull final Filter tuneFilter, final Map valueMap) { + final GPUImageFilter filter = tuneFilter.getInstance(); + filterGroup.addFilter(filter); + if (valueMap == null) return; + final Set> entries = valueMap.entrySet(); + for (final Map.Entry entry : entries) { + tuneFilter.adjust(entry.getKey(), entry.getValue()); + } + } + + public void setCurrentTab(final ImageEditViewModel.Tab tab) { + viewModel.setCurrentTab(tab); + } + + public void setCallback(final FilterCallback callback) { + if (callback == null) return; + this.callback = callback; + } + + public interface FilterCallback { + void onApply(final Uri uri, List> tuningFilters, Filter filter); + + void onCancel(); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/ImageEditFragment.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/ImageEditFragment.java new file mode 100644 index 0000000..034b670 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/ImageEditFragment.java @@ -0,0 +1,297 @@ +package awais.instagrabber.fragments.imageedit; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.SavedStateHandle; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavBackStackEntry; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.imagepipeline.request.ImageRequestBuilder; +import com.yalantis.ucrop.UCrop; +import com.yalantis.ucrop.UCropActivity; +import com.yalantis.ucrop.UCropFragment; +import com.yalantis.ucrop.UCropFragmentCallback; + +import java.io.File; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.FragmentImageEditBinding; +import awais.instagrabber.fragments.imageedit.filters.filters.Filter; +import awais.instagrabber.models.SavedImageEditState; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.ImageEditViewModel; + +public class ImageEditFragment extends Fragment { + private static final String TAG = ImageEditFragment.class.getSimpleName(); + private static final String ARGS_URI = "uri"; + private static final String FILTERS_FRAGMENT_TAG = "Filters"; + + private FragmentImageEditBinding binding; + private ImageEditViewModel viewModel; + private ImageEditViewModel.Tab previousTab; + private FiltersFragment filtersFragment; + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + setEnabled(false); + remove(); + if (previousTab != ImageEditViewModel.Tab.CROP + && previousTab != ImageEditViewModel.Tab.TUNE + && previousTab != ImageEditViewModel.Tab.FILTERS) { + return; + } + final FragmentManager fragmentManager = getChildFragmentManager(); + final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.setReorderingAllowed(true) + .remove(previousTab == ImageEditViewModel.Tab.CROP ? uCropFragment : filtersFragment) + .commit(); + viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); + } + }; + private FragmentActivity fragmentActivity; + private UCropFragment uCropFragment; + + public static ImageEditFragment newInstance(final Uri uri) { + final Bundle args = new Bundle(); + args.putParcelable(ARGS_URI, uri); + final ImageEditFragment fragment = new ImageEditFragment(); + fragment.setArguments(args); + return fragment; + } + + public ImageEditFragment() {} + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = getActivity(); + viewModel = new ViewModelProvider(this).get(ImageEditViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = FragmentImageEditBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + } + + private void init() { + setupObservers(); + final Bundle arguments = getArguments(); + if (arguments == null) return; + final Parcelable parcelable = arguments.getParcelable(ARGS_URI); + Uri originalUri = null; + if (parcelable instanceof Uri) { + originalUri = (Uri) parcelable; + } + if (originalUri == null) return; + viewModel.setOriginalUri(originalUri); + viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); + } + + private void setupObservers() { + viewModel.isLoading().observe(getViewLifecycleOwner(), loading -> {}); + viewModel.getCurrentTab().observe(getViewLifecycleOwner(), tab -> { + if (tab == null) return; + switch (tab) { + case RESULT: + setupResult(); + break; + case CROP: + setupCropFragment(); + break; + case TUNE: + case FILTERS: + setupFilterFragment(); + break; + } + previousTab = tab; + }); + viewModel.isCropped().observe(getViewLifecycleOwner(), isCropped -> binding.crop.setSelected(isCropped)); + viewModel.isTuned().observe(getViewLifecycleOwner(), isTuned -> binding.tune.setSelected(isTuned)); + viewModel.isFiltered().observe(getViewLifecycleOwner(), isFiltered -> binding.filters.setSelected(isFiltered)); + viewModel.getResultUri().observe(getViewLifecycleOwner(), uri -> { + if (uri == null) { + binding.preview.setController(null); + return; + } + binding.preview.setController(Fresco.newDraweeControllerBuilder() + .setImageRequest(ImageRequestBuilder.newBuilderWithSource(uri) + .disableDiskCache() + .disableMemoryCache() + .build()) + .build()); + }); + } + + private void setupResult() { + binding.fragmentContainerView.setVisibility(View.GONE); + binding.cropBottomControls.setVisibility(View.GONE); + binding.preview.setVisibility(View.VISIBLE); + binding.resultBottomControls.setVisibility(View.VISIBLE); + binding.crop.setOnClickListener(v -> viewModel.setCurrentTab(ImageEditViewModel.Tab.CROP)); + binding.tune.setOnClickListener(v -> viewModel.setCurrentTab(ImageEditViewModel.Tab.TUNE)); + binding.filters.setOnClickListener(v -> viewModel.setCurrentTab(ImageEditViewModel.Tab.FILTERS)); + binding.cancel.setOnClickListener(v -> { + viewModel.cancel(); + final NavController navController = NavHostFragment.findNavController(this); + setNavControllerResult(navController, null); + navController.navigateUp(); + }); + binding.done.setOnClickListener(v -> { + final Context context = getContext(); + if (context == null) return; + final Uri resultUri = viewModel.getResultUri().getValue(); + if (resultUri == null) return; + AppExecutors.INSTANCE.getMainThread().execute(() -> { + final NavController navController = NavHostFragment.findNavController(this); + setNavControllerResult(navController, resultUri); + navController.navigateUp(); + }); + // Utils.mediaScanFile(context, new File(resultUri.toString()), (path, uri) -> ); + }); + } + + private void setNavControllerResult(@NonNull final NavController navController, final Uri resultUri) { + final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); + if (navBackStackEntry == null) return; + final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); + savedStateHandle.set("result", resultUri); + } + + private void setupCropFragment() { + final Context context = getContext(); + if (context == null) return; + binding.preview.setVisibility(View.GONE); + binding.resultBottomControls.setVisibility(View.GONE); + binding.fragmentContainerView.setVisibility(View.VISIBLE); + binding.cropBottomControls.setVisibility(View.VISIBLE); + final UCrop.Options options = new UCrop.Options(); + options.setCompressionFormat(Bitmap.CompressFormat.JPEG); + options.setFreeStyleCropEnabled(true); + options.setAllowedGestures(UCropActivity.SCALE, UCropActivity.ROTATE, UCropActivity.ALL); + final UCrop uCrop = UCrop.of(viewModel.getOriginalUri(), viewModel.getCropDestinationUri()).withOptions(options); + final SavedImageEditState savedState = viewModel.getSavedImageEditState(); + if (savedState != null && savedState.getCropImageMatrixValues() != null && savedState.getCropRect() != null) { + uCrop.withSavedState(savedState.getCropImageMatrixValues(), savedState.getCropRect()); + } + uCropFragment = uCrop.getFragment(uCrop.getIntent(context).getExtras()); + final FragmentManager fragmentManager = getChildFragmentManager(); + uCropFragment.setCallback(new UCropFragmentCallback() { + @Override + public void loadingProgress(final boolean showLoader) { + Log.d(TAG, "loadingProgress: " + showLoader); + } + + @Override + public void onCropFinish(final UCropFragment.UCropResult result) { + Log.d(TAG, "onCropFinish: " + result.mResultCode); + if (result.mResultCode == AppCompatActivity.RESULT_OK) { + final Intent resultData = result.mResultData; + final Bundle extras = resultData.getExtras(); + if (extras == null) return; + final Object uri = extras.get(UCrop.EXTRA_OUTPUT_URI); + final Object imageMatrixValues = extras.get(UCrop.EXTRA_IMAGE_MATRIX_VALUES); + final Object cropRect = extras.get(UCrop.EXTRA_CROP_RECT); + if (uri instanceof Uri && imageMatrixValues instanceof float[] && cropRect instanceof RectF) { + Log.d(TAG, "onCropFinish: result uri: " + uri); + viewModel.setCropResult((float[]) imageMatrixValues, (RectF) cropRect); + viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); + } + } + } + }); + final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.setReorderingAllowed(true) + .replace(R.id.fragment_container_view, uCropFragment, UCropFragment.TAG) + .commit(); + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + binding.cropCancel.setOnClickListener(v -> onBackPressedCallback.handleOnBackPressed()); + binding.cropReset.setOnClickListener(v -> uCropFragment.reset()); + binding.cropDone.setOnClickListener(v -> uCropFragment.cropAndSaveImage()); + } + + private void setupFilterFragment() { + binding.resultBottomControls.setVisibility(View.GONE); + binding.preview.setVisibility(View.GONE); + binding.cropBottomControls.setVisibility(View.GONE); + binding.fragmentContainerView.setVisibility(View.VISIBLE); + final Boolean isCropped = viewModel.isCropped().getValue(); + final Uri uri = isCropped != null && isCropped ? viewModel.getCropDestinationUri() : viewModel.getOriginalUri(); + final ImageEditViewModel.Tab value = viewModel.getCurrentTab().getValue(); + final SavedImageEditState savedImageEditState = viewModel.getSavedImageEditState(); + filtersFragment = FiltersFragment.newInstance( + uri, + viewModel.getDestinationUri(), + savedImageEditState.getAppliedTuningFilters(), + savedImageEditState.getAppliedFilter(), + value == null ? ImageEditViewModel.Tab.TUNE : value + ); + final FragmentManager fragmentManager = getChildFragmentManager(); + final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.setReorderingAllowed(true) + .replace(R.id.fragment_container_view, filtersFragment, FILTERS_FRAGMENT_TAG) + .commit(); + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + filtersFragment.setCallback(new FiltersFragment.FilterCallback() { + @Override + public void onApply(final Uri uri, final List> tuningFilters, final Filter filter) { + viewModel.setAppliedFilters(tuningFilters, filter); + viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); + } + + @Override + public void onCancel() { + onBackPressedCallback.handleOnBackPressed(); + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/FiltersHelper.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/FiltersHelper.java new file mode 100644 index 0000000..421df3f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/FiltersHelper.java @@ -0,0 +1,129 @@ +package awais.instagrabber.fragments.imageedit.filters; + +import com.google.common.collect.ImmutableList; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.fragments.imageedit.filters.filters.Filter; +import awais.instagrabber.fragments.imageedit.filters.filters.FilterFactory; + +public final class FiltersHelper { + + public static List> getTuneFilters() { + return TUNING_FILTERS.stream() + .map(FilterFactory::getInstance) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public static List> getFilters() { + // Return all non-tuning filters + return Arrays.stream(FilterType.values()) + .filter(filterType -> !TUNING_FILTERS.contains(filterType)) + .map(FilterFactory::getInstance) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public static final List TUNING_FILTERS = ImmutableList.of( + FilterType.BRIGHTNESS, + FilterType.CONTRAST, + FilterType.VIBRANCE, + FilterType.SATURATION, + FilterType.SHARPEN, + FilterType.EXPOSURE + ); + + public enum FilterType { + // Tune + BRIGHTNESS, + CONTRAST, + VIBRANCE, + SATURATION, + SHARPEN, + EXPOSURE, + + // Filters + NORMAL, + SEPIA, + CLARENDON, + ONE977, + ADEN, + VIGNETTE, + // BILATERAL_BLUR, + // BOX_BLUR, + // BULGE_DISTORTION, + // CGA_COLORSPACE, + // COLOR_BALANCE, + // CROSSHATCH, + // DILATION, + // EMBOSS, + // FALSE_COLOR, + // GAMMA, + // GAUSSIAN_BLUR, + // GLASS_SPHERE, + // GRAYSCALE, + // HALFTONE, + // HAZE, + // HIGHLIGHT_SHADOW, + // HUE, + // INVERT, + // KUWAHARA, + // LAPLACIAN, + // LEVELS_FILTER_MIN, + // LOOKUP_AMATORKA, + // LUMINANCE, + // LUMINANCE_THRESHOLD, + // MONOCHROME, + // NON_MAXIMUM_SUPPRESSION, + // OPACITY, + // PIXELATION, + // POSTERIZE, + // RGB, + // RGB_DILATION, + // SKETCH, + // SMOOTH_TOON, + // SOBEL_EDGE_DETECTION, + // SOLARIZE, + // SPHERE_REFRACTION, + // SWIRL, + // THREE_X_THREE_CONVOLUTION, + // THRESHOLD_EDGE_DETECTION, + // TONE_CURVE, + // TOON, + // TRANSFORM2D, + // WEAK_PIXEL_INCLUSION, + // ZOOM_BLUR + + // Can be separate tunings + // WHITE_BALANCE, + + // BLEND_ADD, + // BLEND_ALPHA, + // BLEND_CHROMA_KEY, + // BLEND_COLOR, + // BLEND_COLOR_BURN, + // BLEND_COLOR_DODGE, + // BLEND_DARKEN, + // BLEND_DIFFERENCE, + // BLEND_DISSOLVE, + // BLEND_DIVIDE, + // BLEND_EXCLUSION, + // BLEND_HARD_LIGHT, + // BLEND_HUE, + // BLEND_LIGHTEN, + // BLEND_LINEAR_BURN, + // BLEND_LUMINOSITY, + // BLEND_MULTIPLY, + // BLEND_NORMAL, + // BLEND_OVERLAY, + // BLEND_SATURATION, + // BLEND_SCREEN, + // BLEND_SOFT_LIGHT, + // BLEND_SOURCE_OVER, + // BLEND_SUBTRACT, + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImage1977Filter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImage1977Filter.java new file mode 100644 index 0000000..687c959 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImage1977Filter.java @@ -0,0 +1,16 @@ +package awais.instagrabber.fragments.imageedit.filters.custom; + +import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageContrastFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageHueFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; + +public class GPUImage1977Filter extends GPUImageFilterGroup { + public GPUImage1977Filter() { + addFilter(new GPUImageSepiaToneFilter(0.35f)); + addFilter(new GPUImageHueFilter(-30f)); + addFilter(new GPUImageSaturationFilter(1.4f)); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageAdenFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageAdenFilter.java new file mode 100644 index 0000000..3620c5a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageAdenFilter.java @@ -0,0 +1,26 @@ +package awais.instagrabber.fragments.imageedit.filters.custom; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; + +import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageMultiplyBlendFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; + +public class GPUImageAdenFilter extends GPUImageFilterGroup { + public GPUImageAdenFilter() { + super(); + addFilter(new GPUImageSepiaToneFilter(0.2f)); + addFilter(new GPUImageBrightnessFilter(0.125f)); + addFilter(new GPUImageSaturationFilter(1.4f)); + final GPUImageMultiplyBlendFilter blendFilter = new GPUImageMultiplyBlendFilter(); + final Bitmap bitmap = Bitmap.createBitmap(5, 5, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + canvas.drawColor(Color.argb((int) (0.1 * 255), 125, 105, 24)); + blendFilter.setBitmap(bitmap); + addFilter(blendFilter); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageClarendonFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageClarendonFilter.java new file mode 100644 index 0000000..db4c05f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageClarendonFilter.java @@ -0,0 +1,30 @@ +package awais.instagrabber.fragments.imageedit.filters.custom; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; + +import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageContrastFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageHueFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageOverlayBlendFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; + +public class GPUImageClarendonFilter extends GPUImageFilterGroup { + public GPUImageClarendonFilter() { + super(); + addFilter(new GPUImageBrightnessFilter(0.15f)); + addFilter(new GPUImageContrastFilter(1.25f)); + addFilter(new GPUImageSaturationFilter(1.15f)); + addFilter(new GPUImageSepiaToneFilter(0.15f)); + addFilter(new GPUImageHueFilter(5)); + final GPUImageOverlayBlendFilter blendFilter = new GPUImageOverlayBlendFilter(); + final Bitmap bitmap = Bitmap.createBitmap(5, 5, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + canvas.drawColor(Color.argb((int) (0.4 * 255), 127, 187, 227)); + blendFilter.setBitmap(bitmap); + addFilter(blendFilter); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/AdenFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/AdenFilter.java new file mode 100644 index 0000000..8744714 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/AdenFilter.java @@ -0,0 +1,28 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.custom.GPUImageAdenFilter; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; + +public class AdenFilter extends Filter { + + private final GPUImageAdenFilter filter; + + public AdenFilter() { + super(FiltersHelper.FilterType.ADEN, R.string.aden); + filter = new GPUImageAdenFilter(); + } + + @Override + public GPUImageAdenFilter getInstance() { + return filter; + } + + @Override + public Map> getProperties() { + return null; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BilateralBlurFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BilateralBlurFilter.java new file mode 100644 index 0000000..5f937d2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BilateralBlurFilter.java @@ -0,0 +1,42 @@ +// package awais.instagrabber.fragments.imageedit.filters.filters; +// +// import java.util.Collections; +// import java.util.Map; +// +// import awais.instagrabber.R; +// import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +// import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +// import awais.instagrabber.fragments.imageedit.filters.properties.Property; +// import jp.co.cyberagent.android.gpuimage.filter.GPUImageBilateralBlurFilter; +// +// public class BilateralBlurFilter extends Filter { +// private static final int PROP_DISTANCE = 0; +// +// private final GPUImageBilateralBlurFilter filter; +// private final Map> properties; +// +// public BilateralBlurFilter() { +// super(FiltersHelper.FilterType.BILATERAL_BLUR, R.string.bilateral_blur); +// properties = Collections.singletonMap( +// PROP_DISTANCE, new FloatProperty(-1, 8f, 0f, 15.0f) +// ); +// filter = new GPUImageBilateralBlurFilter((Float) getProperty(PROP_DISTANCE).getDefaultValue()); +// } +// +// @Override +// public Map> getProperties() { +// return properties; +// } +// +// @Override +// public void adjust(final int property, final Object value) { +// super.adjust(property, value); +// if (!(value instanceof Float)) return; +// filter.setDistanceNormalizationFactor((Float) value); +// } +// +// @Override +// public GPUImageBilateralBlurFilter getInstance() { +// return filter; +// } +// } diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BoxBlurFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BoxBlurFilter.java new file mode 100644 index 0000000..8f513a4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BoxBlurFilter.java @@ -0,0 +1,42 @@ +// package awais.instagrabber.fragments.imageedit.filters.filters; +// +// import java.util.Collections; +// import java.util.Map; +// +// import awais.instagrabber.R; +// import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +// import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +// import awais.instagrabber.fragments.imageedit.filters.properties.Property; +// import jp.co.cyberagent.android.gpuimage.filter.GPUImageBoxBlurFilter; +// +// public class BoxBlurFilter extends Filter { +// private static final int PROP_SIZE = 0; +// +// private final GPUImageBoxBlurFilter filter; +// private final Map> properties; +// +// public BoxBlurFilter() { +// super(FiltersHelper.FilterType.BOX_BLUR, R.string.box_blur); +// properties = Collections.singletonMap( +// PROP_SIZE, new FloatProperty(-1, 1f, 1f, 10.0f) +// ); +// filter = new GPUImageBoxBlurFilter((Float) getProperty(PROP_SIZE).getDefaultValue()); +// } +// +// @Override +// public Map> getProperties() { +// return properties; +// } +// +// @Override +// public void adjust(final int property, final Object value) { +// super.adjust(property, value); +// if (!(value instanceof Float)) return; +// filter.setBlurSize((Float) value); +// } +// +// @Override +// public GPUImageBoxBlurFilter getInstance() { +// return filter; +// } +// } diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BrightnessFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BrightnessFilter.java new file mode 100644 index 0000000..14353ff --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BrightnessFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; + +public class BrightnessFilter extends Filter { + private static final int PROP_BRIGHTNESS = 0; + + private final GPUImageBrightnessFilter filter; + private final Map> properties; + + public BrightnessFilter() { + super(FiltersHelper.FilterType.BRIGHTNESS, R.string.brightness); + properties = Collections.singletonMap( + PROP_BRIGHTNESS, new FloatProperty(R.string.brightness, 0.0f, -1.0f, 1.0f) + ); + filter = new GPUImageBrightnessFilter((Float) getProperty(PROP_BRIGHTNESS).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setBrightness((Float) value); + } + + @Override + public GPUImageBrightnessFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ClarendonFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ClarendonFilter.java new file mode 100644 index 0000000..f27fa20 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ClarendonFilter.java @@ -0,0 +1,28 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.custom.GPUImageClarendonFilter; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; + +public class ClarendonFilter extends Filter { + + private final GPUImageClarendonFilter filter; + + public ClarendonFilter() { + super(FiltersHelper.FilterType.CLARENDON, R.string.clarendon); + filter = new GPUImageClarendonFilter(); + } + + @Override + public GPUImageClarendonFilter getInstance() { + return filter; + } + + @Override + public Map> getProperties() { + return null; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ContrastFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ContrastFilter.java new file mode 100644 index 0000000..83ae452 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ContrastFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageContrastFilter; + +public class ContrastFilter extends Filter { + private static final int PROP_CONTRAST = 0; + + private final GPUImageContrastFilter filter; + private final Map> properties; + + public ContrastFilter() { + super(FiltersHelper.FilterType.CONTRAST, R.string.contrast); + properties = Collections.singletonMap( + PROP_CONTRAST, new FloatProperty(R.string.contrast, 1.0f, 0.0f, 4.0f) + ); + filter = new GPUImageContrastFilter((Float) getProperty(PROP_CONTRAST).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setContrast((Float) value); + } + + @Override + public GPUImageContrastFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ExposureFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ExposureFilter.java new file mode 100644 index 0000000..066e528 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ExposureFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageExposureFilter; + +public class ExposureFilter extends Filter { + private static final int PROP_EXPOSURE = 0; + + private final GPUImageExposureFilter filter; + private final Map> properties; + + public ExposureFilter() { + super(FiltersHelper.FilterType.EXPOSURE, R.string.exposure); + properties = Collections.singletonMap( + PROP_EXPOSURE, new FloatProperty(R.string.exposure, 0f, -3.0f, 3.0f) + ); + filter = new GPUImageExposureFilter((Float) getProperty(PROP_EXPOSURE).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setExposure((Float) value); + } + + @Override + public GPUImageExposureFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/Filter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/Filter.java new file mode 100644 index 0000000..47035f4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/Filter.java @@ -0,0 +1,53 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import androidx.annotation.CallSuper; +import androidx.annotation.StringRes; + +import java.util.Map; +import java.util.Set; + +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; + +public abstract class Filter { + private final FiltersHelper.FilterType type; + private final int label; + + public Filter(final FiltersHelper.FilterType type, @StringRes final int label) { + this.type = type; + this.label = label; + } + + public FiltersHelper.FilterType getType() { + return type; + } + + @StringRes + public int getLabel() { + return label; + } + + public abstract T getInstance(); + + public abstract Map> getProperties(); + + public Property getProperty(int property) { + return getProperties().get(property); + } + + @CallSuper + public void adjust(final int property, final Object value) { + final Property propertyObj = getProperty(property); + propertyObj.setValue(value); + } + + public void reset() { + final Map> propertyMap = getProperties(); + if (propertyMap == null) return; + final Set>> entries = propertyMap.entrySet(); + for (final Map.Entry> entry : entries) { + adjust(entry.getKey(), entry.getValue().getDefaultValue()); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/FilterFactory.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/FilterFactory.java new file mode 100644 index 0000000..104f4d5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/FilterFactory.java @@ -0,0 +1,126 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; + +public final class FilterFactory { + + public static Filter getInstance(final FiltersHelper.FilterType type) { + switch (type) { + case BRIGHTNESS: + return new BrightnessFilter(); + case CONTRAST: + return new ContrastFilter(); + case VIBRANCE: + return new VibranceFilter(); + case SATURATION: + return new SaturationFilter(); + case SHARPEN: + return new SharpenFilter(); + case EXPOSURE: + return new ExposureFilter(); + case NORMAL: + return new NormalFilter(); + case SEPIA: + return new SepiaToneFilter(); + case CLARENDON: + return new ClarendonFilter(); + case ONE977: + return new One977Filter(); + case ADEN: + return new AdenFilter(); + // case BULGE_DISTORTION: + // break; + // case CGA_COLORSPACE: + // break; + // case COLOR_BALANCE: + // break; + // case CROSSHATCH: + // break; + // case DILATION: + // break; + // case EMBOSS: + // break; + // case FALSE_COLOR: + // break; + // case GAMMA: + // break; + // case GAUSSIAN_BLUR: + // break; + // case GLASS_SPHERE: + // break; + // case GRAYSCALE: + // break; + // case HALFTONE: + // break; + // case HAZE: + // break; + // case HIGHLIGHT_SHADOW: + // break; + // case HUE: + // break; + // case INVERT: + // break; + // case KUWAHARA: + // break; + // case LAPLACIAN: + // break; + // case LEVELS_FILTER_MIN: + // break; + // case LOOKUP_AMATORKA: + // break; + // case LUMINANCE: + // break; + // case LUMINANCE_THRESHOLD: + // break; + // case MONOCHROME: + // break; + // case NON_MAXIMUM_SUPPRESSION: + // break; + // case OPACITY: + // break; + // case PIXELATION: + // break; + // case POSTERIZE: + // break; + // case RGB: + // break; + // case RGB_DILATION: + // break; + // case SKETCH: + // break; + // case SMOOTH_TOON: + // break; + // case SOBEL_EDGE_DETECTION: + // break; + // case SOLARIZE: + // break; + // case SPHERE_REFRACTION: + // break; + // case SWIRL: + // break; + // case THREE_X_THREE_CONVOLUTION: + // break; + // case THRESHOLD_EDGE_DETECTION: + // break; + // case TONE_CURVE: + // break; + // case TOON: + // break; + // case TRANSFORM2D: + // break; + // case WEAK_PIXEL_INCLUSION: + // break; + // case WHITE_BALANCE: + // break; + // case ZOOM_BLUR: + // break; + case VIGNETTE: + return new VignetteFilter(); + // case BILATERAL_BLUR: + // return new BilateralBlurFilter(); + // case BOX_BLUR: + // return new BoxBlurFilter(); + } + return null; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/NormalFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/NormalFilter.java new file mode 100644 index 0000000..89eb58c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/NormalFilter.java @@ -0,0 +1,30 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; + +public class NormalFilter extends Filter { + private final GPUImageFilter filter; + private final Map> properties; + + public NormalFilter() { + super(FiltersHelper.FilterType.NORMAL, R.string.normal); + properties = Collections.emptyMap(); + filter = new GPUImageFilter(); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public GPUImageFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/One977Filter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/One977Filter.java new file mode 100644 index 0000000..683f226 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/One977Filter.java @@ -0,0 +1,28 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.custom.GPUImage1977Filter; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; + +public class One977Filter extends Filter { + + private final GPUImage1977Filter filter; + + public One977Filter() { + super(FiltersHelper.FilterType.ONE977, R.string.one977); + filter = new GPUImage1977Filter(); + } + + @Override + public GPUImage1977Filter getInstance() { + return filter; + } + + @Override + public Map> getProperties() { + return null; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SaturationFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SaturationFilter.java new file mode 100644 index 0000000..54189c3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SaturationFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; + +public class SaturationFilter extends Filter { + private static final int PROP_SATURATION = 0; + + private final GPUImageSaturationFilter filter; + private final Map> properties; + + public SaturationFilter() { + super(FiltersHelper.FilterType.SATURATION, R.string.saturation); + properties = Collections.singletonMap( + PROP_SATURATION, new FloatProperty(R.string.saturation, 1.0f, 0f, 2.0f) + ); + filter = new GPUImageSaturationFilter((Float) getProperty(PROP_SATURATION).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setSaturation((Float) value); + } + + @Override + public GPUImageSaturationFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SepiaToneFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SepiaToneFilter.java new file mode 100644 index 0000000..9b66609 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SepiaToneFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; + +public class SepiaToneFilter extends Filter { + private static final int PROP_INTENSITY = 0; + + private final GPUImageSepiaToneFilter filter; + private final Map> properties; + + public SepiaToneFilter() { + super(FiltersHelper.FilterType.SEPIA, R.string.sepia); + properties = Collections.singletonMap( + PROP_INTENSITY, new FloatProperty(-1, 1f, 1f, 10.0f) + ); + filter = new GPUImageSepiaToneFilter((Float) getProperty(PROP_INTENSITY).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setIntensity((Float) value); + } + + @Override + public GPUImageSepiaToneFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SharpenFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SharpenFilter.java new file mode 100644 index 0000000..f0417ca --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SharpenFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageSharpenFilter; + +public class SharpenFilter extends Filter { + private static final int PROP_SHARPNESS = 0; + + private final GPUImageSharpenFilter filter; + private final Map> properties; + + public SharpenFilter() { + super(FiltersHelper.FilterType.SHARPEN, R.string.sharpen); + properties = Collections.singletonMap( + PROP_SHARPNESS, new FloatProperty(R.string.sharpen, 0f, -0.5f, 0.5f) + ); + filter = new GPUImageSharpenFilter((Float) getProperty(PROP_SHARPNESS).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setSharpness((Float) value); + } + + @Override + public GPUImageSharpenFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VibranceFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VibranceFilter.java new file mode 100644 index 0000000..7cf276a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VibranceFilter.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import java.util.Collections; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageVibranceFilter; + +public class VibranceFilter extends Filter { + private static final int PROP_VIBRANCE = 0; + + private final GPUImageVibranceFilter filter; + private final Map> properties; + + public VibranceFilter() { + super(FiltersHelper.FilterType.VIBRANCE, R.string.vibrance); + properties = Collections.singletonMap( + PROP_VIBRANCE, new FloatProperty(R.string.vibrance, 0f, -1.2f, 1.2f) + ); + filter = new GPUImageVibranceFilter((Float) getProperty(PROP_VIBRANCE).getDefaultValue()); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + if (!(value instanceof Float)) return; + filter.setVibrance((Float) value); + } + + @Override + public GPUImageVibranceFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VignetteFilter.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VignetteFilter.java new file mode 100644 index 0000000..9b30ae9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VignetteFilter.java @@ -0,0 +1,77 @@ +package awais.instagrabber.fragments.imageedit.filters.filters; + +import android.graphics.Color; +import android.graphics.PointF; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; +import awais.instagrabber.fragments.imageedit.filters.properties.ColorProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.PointFProperty; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageVignetteFilter; + +public class VignetteFilter extends Filter { + private static final int PROP_CENTER = 0; + private static final int PROP_COLOR = 1; + private static final int PROP_START = 2; + private static final int PROP_END = 3; + + private final GPUImageVignetteFilter filter; + private final Map> properties; + + public VignetteFilter() { + super(FiltersHelper.FilterType.VIGNETTE, R.string.vignette); + properties = ImmutableMap.of( + PROP_CENTER, new PointFProperty(R.string.center, new PointF(0.5f, 0.5f)), + PROP_COLOR, new ColorProperty(R.string.color, Color.BLACK), + PROP_START, new FloatProperty(R.string.start, 0.3f), + PROP_END, new FloatProperty(R.string.end, 0.75f) + ); + filter = new GPUImageVignetteFilter( + (PointF) getProperty(PROP_CENTER).getDefaultValue(), + getFloatArrayFromColor((Integer) getProperty(PROP_COLOR).getDefaultValue()), + (Float) getProperty(PROP_START).getDefaultValue(), + (Float) getProperty(PROP_END).getDefaultValue() + ); + } + + @Override + public Map> getProperties() { + return properties; + } + + @Override + public void adjust(final int property, final Object value) { + super.adjust(property, value); + switch (property) { + case PROP_CENTER: + filter.setVignetteCenter((PointF) value); + return; + case PROP_COLOR: + final int color = (int) value; + filter.setVignetteColor(getFloatArrayFromColor(color)); + return; + case PROP_START: + filter.setVignetteStart((float) value); + return; + case PROP_END: + filter.setVignetteEnd((float) value); + return; + default: + } + } + + private float[] getFloatArrayFromColor(final int color) { + return new float[]{Color.red(color) / 255f, Color.green(color) / 255f, Color.blue(color) / 255f}; + } + + @Override + public GPUImageVignetteFilter getInstance() { + return filter; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/ColorProperty.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/ColorProperty.java new file mode 100644 index 0000000..fd1a768 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/ColorProperty.java @@ -0,0 +1,37 @@ +package awais.instagrabber.fragments.imageedit.filters.properties; + +import androidx.annotation.StringRes; + +/** + * Min and Max values do not matter here + */ +public class ColorProperty extends Property { + private final int label; + private final int defaultValue; + + public ColorProperty(@StringRes final int label, + final int defaultValue) { + this.label = label; + this.defaultValue = defaultValue; + } + + @Override + public int getLabel() { + return label; + } + + @Override + public Integer getDefaultValue() { + return defaultValue; + } + + @Override + public Integer getMinValue() { + return defaultValue; + } + + @Override + public Integer getMaxValue() { + return defaultValue; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/FloatProperty.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/FloatProperty.java new file mode 100644 index 0000000..e39ee82 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/FloatProperty.java @@ -0,0 +1,49 @@ +package awais.instagrabber.fragments.imageedit.filters.properties; + +import androidx.annotation.StringRes; + +public class FloatProperty extends Property { + + private final int label; + private final float defaultValue; + private final float minValue; + private final float maxValue; + + public FloatProperty(@StringRes final int label, + final float defaultValue, + final float minValue, + final float maxValue) { + + this.label = label; + this.defaultValue = defaultValue; + this.minValue = minValue; + this.maxValue = maxValue; + } + + public FloatProperty(@StringRes final int label, final float value) { + this.label = label; + this.defaultValue = value; + this.minValue = value; + this.maxValue = value; + } + + @Override + public int getLabel() { + return label; + } + + @Override + public Float getDefaultValue() { + return defaultValue; + } + + @Override + public Float getMinValue() { + return minValue; + } + + @Override + public Float getMaxValue() { + return maxValue; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/PointFProperty.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/PointFProperty.java new file mode 100644 index 0000000..505a20d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/PointFProperty.java @@ -0,0 +1,39 @@ +package awais.instagrabber.fragments.imageedit.filters.properties; + +import android.graphics.PointF; + +import androidx.annotation.StringRes; + +/** + * Min and Max values do not matter here + */ +public class PointFProperty extends Property { + private final int label; + private final PointF defaultValue; + + public PointFProperty(@StringRes final int label, + final PointF defaultValue) { + this.label = label; + this.defaultValue = defaultValue; + } + + @Override + public int getLabel() { + return label; + } + + @Override + public PointF getDefaultValue() { + return defaultValue; + } + + @Override + public PointF getMinValue() { + return defaultValue; + } + + @Override + public PointF getMaxValue() { + return defaultValue; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/Property.java b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/Property.java new file mode 100644 index 0000000..ccf66b2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/Property.java @@ -0,0 +1,36 @@ +package awais.instagrabber.fragments.imageedit.filters.properties; + +import android.util.Log; + +import androidx.annotation.StringRes; + +public abstract class Property { + private static final String TAG = Property.class.getSimpleName(); + protected T value; + + @StringRes + public abstract int getLabel(); + + public abstract T getDefaultValue(); + + public abstract T getMinValue(); + + public abstract T getMaxValue(); + + public T getValue() { + return value; + } + + public void setValue(final Object value) { + try { + //noinspection unchecked + this.value = (T) value; + } catch (ClassCastException e) { + Log.e(TAG, "setValue: ", e); + } + } + + public void reset() { + setValue(getDefaultValue()); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/main/DiscoverFragment.java b/app/src/main/java/awais/instagrabber/fragments/main/DiscoverFragment.java new file mode 100644 index 0000000..1f832c6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/main/DiscoverFragment.java @@ -0,0 +1,173 @@ +package awais.instagrabber.fragments.main; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.FragmentNavigator; +import androidx.navigation.fragment.NavHostFragment; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.DiscoverTopicsAdapter; +import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; +import awais.instagrabber.databinding.FragmentDiscoverBinding; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.discover.TopicCluster; +import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.TopicClusterViewModel; +import awais.instagrabber.webservices.DiscoverService; +import awais.instagrabber.webservices.MediaRepository; +import awais.instagrabber.webservices.ServiceCallback; +import kotlinx.coroutines.Dispatchers; + +public class DiscoverFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "DiscoverFragment"; + + private MainActivity fragmentActivity; + private CoordinatorLayout root; + private FragmentDiscoverBinding binding; + private TopicClusterViewModel topicClusterViewModel; + private boolean shouldRefresh = true; + private DiscoverService discoverService; + private MediaRepository mediaRepository; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + discoverService = DiscoverService.getInstance(); + // final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); + // final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); + // final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + // final long userId = CookieUtils.getUserIdFromCookie(cookie); + mediaRepository = MediaRepository.Companion.getInstance(); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentDiscoverBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.swipeRefreshLayout.setOnRefreshListener(this); + init(); + shouldRefresh = false; + } + + private void init() { + setupTopics(); + fetchTopics(); + } + + @Override + public void onRefresh() { + fetchTopics(); + } + + public void setupTopics() { + topicClusterViewModel = new ViewModelProvider(fragmentActivity).get(TopicClusterViewModel.class); + binding.topicsRecyclerView.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(2))); + final DiscoverTopicsAdapter.OnTopicClickListener otcl = new DiscoverTopicsAdapter.OnTopicClickListener() { + public void onTopicClick(final TopicCluster topicCluster, final View cover, final int titleColor, final int backgroundColor) { + try { + final FragmentNavigator.Extras.Builder builder = new FragmentNavigator.Extras.Builder() + .addSharedElement(cover, "cover-" + topicCluster.getId()); + final NavDirections action = DiscoverFragmentDirections.actionToTopicPosts(topicCluster, titleColor, backgroundColor); + NavHostFragment.findNavController(DiscoverFragment.this).navigate(action, builder.build()); + } catch (Exception e) { + Log.e(TAG, "onTopicClick: ", e); + } + } + + public void onTopicLongClick(final Media coverMedia) { + final AlertDialog alertDialog = new AlertDialog.Builder(requireContext()) + .setCancelable(false) + .setView(R.layout.dialog_opening_post) + .create(); + alertDialog.show(); + final String pk = coverMedia.getPk(); + if (pk == null) return; + mediaRepository.fetch( + Long.parseLong(pk), + CoroutineUtilsKt.getContinuation((media, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + alertDialog.dismiss(); + try { + Toast.makeText(requireContext(), R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); + } catch (Throwable ignored) {} + return; + } + try { + final NavDirections action = DiscoverFragmentDirections.actionToPost(media, 0); + NavHostFragment.findNavController(DiscoverFragment.this).navigate(action); + alertDialog.dismiss(); + } catch (Exception e) { + Log.e(TAG, "onTopicLongClick: ", e); + } + }), Dispatchers.getIO()) + ); + } + }; + final DiscoverTopicsAdapter adapter = new DiscoverTopicsAdapter(otcl); + binding.topicsRecyclerView.setAdapter(adapter); + topicClusterViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); + } + + private void fetchTopics() { + binding.swipeRefreshLayout.setRefreshing(true); + discoverService.topicalExplore(new DiscoverService.TopicalExploreRequest(), new ServiceCallback() { + @Override + public void onSuccess(final TopicalExploreFeedResponse result) { + if (result == null) return; + final List clusters = result.getClusters(); + if (clusters == null || result.getItems() == null) return; + binding.swipeRefreshLayout.setRefreshing(false); + if (clusters.size() == 1 && result.getItems().size() > 0) { + final TopicCluster cluster = clusters.get(0); + if (cluster.getCoverMedia() == null) { + cluster.setCoverMedia(result.getItems().get(0).getMedia()); + } + topicClusterViewModel.getList().postValue(Collections.singletonList(cluster)); + return; + } + if (clusters.size() > 1 || result.getItems().size() == 0) { + topicClusterViewModel.getList().postValue(clusters); + } + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure", t); + binding.swipeRefreshLayout.setRefreshing(false); + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/main/FeedFragment.java b/app/src/main/java/awais/instagrabber/fragments/main/FeedFragment.java new file mode 100644 index 0000000..e1dbc58 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/main/FeedFragment.java @@ -0,0 +1,437 @@ +package awais.instagrabber.fragments.main; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.common.collect.ImmutableList; + +import java.util.List; +import java.util.Set; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.FeedAdapterV2; +import awais.instagrabber.adapters.FeedStoriesAdapter; +import awais.instagrabber.asyncs.FeedPostFetchService; +import awais.instagrabber.customviews.PrimaryActionModeCallback; +import awais.instagrabber.databinding.FragmentFeedBinding; +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.repositories.requests.StoryViewerOptions; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.stories.Story; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.viewmodels.FeedStoriesViewModel; +import awais.instagrabber.webservices.StoriesRepository; +import kotlinx.coroutines.Dispatchers; + +public class FeedFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = "FeedFragment"; + + private MainActivity fragmentActivity; + private CoordinatorLayout root; + private FragmentFeedBinding binding; + private StoriesRepository storiesRepository; + private boolean shouldRefresh = true; + private FeedStoriesViewModel feedStoriesViewModel; + private boolean storiesFetching; + private ActionMode actionMode; + private Set selectedFeedModels; + private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_POSTS_LAYOUT); + private MenuItem storyListMenu; + + private final FeedStoriesAdapter feedStoriesAdapter = new FeedStoriesAdapter( + new FeedStoriesAdapter.OnFeedStoryClickListener() { + @Override + public void onFeedStoryClick(Story model, int position) { + final NavController navController = NavHostFragment.findNavController(FeedFragment.this); + if (isSafeToNavigate(navController)) { + try { + final NavDirections action = FeedFragmentDirections.actionToStory(StoryViewerOptions.forFeedStoryPosition(position)); + navController.navigate(action); + } catch (Exception e) { + Log.e(TAG, "onFeedStoryClick: ", e); + } + } + } + + @Override + public void onFeedStoryLongClick(Story model, int position) { + final User user = model.getUser(); + if (user == null) return; + navigateToProfile("@" + user.getUsername()); + } + } + ); + + private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { + @Override + public void onPostClick(final Media feedModel) { + openPostDialog(feedModel, -1); + } + + @Override + public void onSliderClick(final Media feedModel, final int position) { + openPostDialog(feedModel, position); + } + + @Override + public void onCommentsClick(final Media feedModel) { + try { + final User user = feedModel.getUser(); + if (user == null) return; + final NavDirections commentsAction = FeedFragmentDirections.actionToComments( + feedModel.getCode(), + feedModel.getPk(), + user.getPk() + ); + NavHostFragment.findNavController(FeedFragment.this).navigate(commentsAction); + } catch (Exception e) { + Log.e(TAG, "onCommentsClick: ", e); + } + } + + @Override + public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { + final Context context = getContext(); + if (context == null) return; + DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); + } + + @Override + public void onHashtagClick(final String hashtag) { + try { + final NavDirections action = FeedFragmentDirections.actionToHashtag(hashtag); + NavHostFragment.findNavController(FeedFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onHashtagClick: ", e); + } + } + + @Override + public void onLocationClick(final Media feedModel) { + final Location location = feedModel.getLocation(); + if (location == null) return; + try { + final NavDirections action = FeedFragmentDirections.actionToLocation(location.getPk()); + NavHostFragment.findNavController(FeedFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onLocationClick: ", e); + } + } + + @Override + public void onMentionClick(final String mention) { + navigateToProfile(mention.trim()); + } + + @Override + public void onNameClick(final Media feedModel) { + if (feedModel.getUser() == null) return; + navigateToProfile("@" + feedModel.getUser().getUsername()); + } + + @Override + public void onProfilePicClick(final Media feedModel) { + if (feedModel.getUser() == null) return; + navigateToProfile("@" + feedModel.getUser().getUsername()); + } + + @Override + public void onURLClick(final String url) { + Utils.openURL(getContext(), url); + } + + @Override + public void onEmailClick(final String emailId) { + Utils.openEmailAddress(getContext(), emailId); + } + + private void openPostDialog(final Media feedModel, final int position) { + try { + final NavDirections action = FeedFragmentDirections.actionToPost(feedModel, position); + NavHostFragment.findNavController(FeedFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "openPostDialog: ", e); + } + } + }; + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + binding.feedRecyclerView.endSelection(); + } + }; + private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( + R.menu.multi_select_download_menu, + new PrimaryActionModeCallback.CallbacksHelper() { + @Override + public void onDestroy(final ActionMode mode) { + binding.feedRecyclerView.endSelection(); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + if (item.getItemId() == R.id.action_download) { + if (FeedFragment.this.selectedFeedModels == null) return false; + final Context context = getContext(); + if (context == null) return false; + DownloadUtils.download(context, ImmutableList.copyOf(FeedFragment.this.selectedFeedModels)); + binding.feedRecyclerView.endSelection(); + return true; + } + return false; + } + }); + private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { + + @Override + public void onSelectionStart() { + if (!onBackPressedCallback.isEnabled()) { + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedCallback.setEnabled(true); + onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); + } + if (actionMode == null) { + actionMode = fragmentActivity.startActionMode(multiSelectAction); + } + } + + @Override + public void onSelectionChange(final Set selectedFeedModels) { + final String title = getString(R.string.number_selected, selectedFeedModels.size()); + if (actionMode != null) { + actionMode.setTitle(title); + } + FeedFragment.this.selectedFeedModels = selectedFeedModels; + } + + @Override + public void onSelectionEnd() { + if (onBackPressedCallback.isEnabled()) { + onBackPressedCallback.setEnabled(false); + onBackPressedCallback.remove(); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }; + + private void navigateToProfile(final String username) { + try { + final NavDirections action = FeedFragmentDirections.actionToProfile().setUsername(username); + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "navigateToProfile: ", e); + } + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + fragmentActivity = (MainActivity) requireActivity(); + storiesRepository = StoriesRepository.Companion.getInstance(); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentFeedBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + binding.feedSwipeRefreshLayout.setOnRefreshListener(this); + /* + FabAnimation.init(binding.fabCamera); + FabAnimation.init(binding.fabStory); + binding.fabAdd.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + isRotate = FabAnimation.rotateFab(v, !isRotate); + if (isRotate) { + FabAnimation.showIn(binding.fabCamera); + FabAnimation.showIn(binding.fabStory); + } + else { + FabAnimation.showOut(binding.fabCamera); + FabAnimation.showOut(binding.fabStory); + } + } + }); + */ + setupFeedStories(); + setupFeed(); + shouldRefresh = false; + } + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.feed_menu, menu); + storyListMenu = menu.findItem(R.id.storyList); + storyListMenu.setVisible(!storiesFetching); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.storyList) { + try { + final NavDirections action = FeedFragmentDirections.actionToStoryList("feed"); + NavHostFragment.findNavController(FeedFragment.this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onOptionsItemSelected: ", e); + } + } else if (item.getItemId() == R.id.layout) { + showPostsLayoutPreferences(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onRefresh() { + binding.feedRecyclerView.refresh(); + fetchStories(); + } + + @Override + public void onResume() { + super.onResume(); + fragmentActivity.setToolbar(binding.toolbar, this); + } + + @Override + public void onStop() { + super.onStop(); + fragmentActivity.resetToolbar(this); + } + + private void setupFeed() { + binding.feedRecyclerView.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(new FeedPostFetchService()) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init(); + // binding.feedRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + // @Override + // public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + // super.onScrolled(recyclerView, dx, dy); + // final boolean canScrollVertically = recyclerView.canScrollVertically(-1); + // final MotionScene.Transition transition = root.getTransition(R.id.transition); + // if (transition != null) { + // transition.setEnable(!canScrollVertically); + // } + // } + // }); + // if (shouldAutoPlay) { + // videoAwareRecyclerScroller = new VideoAwareRecyclerScroller(); + // binding.feedRecyclerView.addOnScrollListener(videoAwareRecyclerScroller); + // } + } + + private void updateSwipeRefreshState() { + AppExecutors.INSTANCE.getMainThread().execute(() -> binding.feedSwipeRefreshLayout + .setRefreshing(binding.feedRecyclerView.isFetching() || storiesFetching) + ); + } + + private void setupFeedStories() { + if (storyListMenu != null) storyListMenu.setVisible(false); + feedStoriesViewModel = new ViewModelProvider(fragmentActivity).get(FeedStoriesViewModel.class); + final Context context = getContext(); + if (context == null) return; + final RecyclerView storiesRecyclerView = binding.header; + storiesRecyclerView.setLayoutManager(new LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)); + storiesRecyclerView.setAdapter(feedStoriesAdapter); + feedStoriesViewModel.getList().observe(fragmentActivity, feedStoriesAdapter::submitList); + fetchStories(); + } + + private void fetchStories() { + if (storiesFetching) return; + // final String cookie = settingsHelper.getString(Constants.COOKIE); + storiesFetching = true; + updateSwipeRefreshState(); + storiesRepository.getFeedStories( + CoroutineUtilsKt.getContinuation((feedStoryModels, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "failed", throwable); + storiesFetching = false; + updateSwipeRefreshState(); + return; + } + storiesFetching = false; + //noinspection unchecked + feedStoriesViewModel.getList().postValue((List) feedStoryModels); + if (storyListMenu != null) storyListMenu.setVisible(true); + updateSwipeRefreshState(); + }), Dispatchers.getIO()) + ); + } + + private void showPostsLayoutPreferences() { + final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( + Constants.PREF_POSTS_LAYOUT, + preferences -> { + layoutPreferences = preferences; + new Handler().postDelayed(() -> binding.feedRecyclerView.setLayoutPreferences(preferences), 200); + } + ); + fragment.show(getChildFragmentManager(), "posts_layout_preferences"); + } + + public void scrollToTop() { + if (binding != null) { + binding.feedRecyclerView.smoothScrollToPosition(0); + // binding.storiesContainer.setExpanded(true); + } + } + + private boolean isSafeToNavigate(final NavController navController) { + return navController.getCurrentDestination() != null + && navController.getCurrentDestination().getId() == R.id.feedFragment; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt new file mode 100644 index 0000000..e5c165a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt @@ -0,0 +1,1014 @@ +package awais.instagrabber.fragments.main + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.SpannableStringBuilder +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.util.Log +import android.view.* +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.TooltipCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import awais.instagrabber.R +import awais.instagrabber.activities.MainActivity +import awais.instagrabber.adapters.FeedAdapterV2 +import awais.instagrabber.adapters.HighlightsAdapter +import awais.instagrabber.asyncs.ProfilePostFetchService +import awais.instagrabber.customviews.PrimaryActionModeCallback +import awais.instagrabber.customviews.RamboTextViewV2 +import awais.instagrabber.customviews.RamboTextViewV2.* +import awais.instagrabber.databinding.FragmentProfileBinding +import awais.instagrabber.db.repositories.FavoriteRepository +import awais.instagrabber.dialogs.ConfirmDialogFragment +import awais.instagrabber.dialogs.ConfirmDialogFragment.ConfirmDialogFragmentCallback +import awais.instagrabber.dialogs.MultiOptionDialogFragment +import awais.instagrabber.dialogs.MultiOptionDialogFragment.MultiOptionDialogSingleCallback +import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment +import awais.instagrabber.dialogs.ProfilePicDialogFragment +import awais.instagrabber.fragments.UserSearchMode +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.models.Resource +import awais.instagrabber.models.enums.PostItemType +import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.FriendshipStatus +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.UserProfileContextLink +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.utils.* +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.utils.extensions.isReallyPrivate +import awais.instagrabber.utils.extensions.trimAll +import awais.instagrabber.viewmodels.AppStateViewModel +import awais.instagrabber.viewmodels.ProfileFragmentViewModel +import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileEvent.* +import awais.instagrabber.viewmodels.ProfileFragmentViewModelFactory +import awais.instagrabber.webservices.* + + +class ProfileFragment : Fragment(), OnRefreshListener, ConfirmDialogFragmentCallback, MultiOptionDialogSingleCallback { + private var backStackSavedStateResultLiveData: MutableLiveData? = null + private var shareDmMenuItem: MenuItem? = null + private var shareLinkMenuItem: MenuItem? = null + private var removeFollowerMenuItem: MenuItem? = null + private var chainingMenuItem: MenuItem? = null + private var mutePostsMenuItem: MenuItem? = null + private var muteStoriesMenuItem: MenuItem? = null + private var restrictMenuItem: MenuItem? = null + private var blockMenuItem: MenuItem? = null + private var setupPostsDone: Boolean = false + private var selectedMedia: List? = null + private var actionMode: ActionMode? = null + private var disableDm: Boolean = false + + // private var shouldRefresh: Boolean = true + private var highlightsAdapter: HighlightsAdapter? = null + private var layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_PROFILE_POSTS_LAYOUT) + + private lateinit var mainActivity: MainActivity + + // private lateinit var root: MotionLayout + private lateinit var binding: FragmentProfileBinding + private lateinit var appStateViewModel: AppStateViewModel + private lateinit var viewModel: ProfileFragmentViewModel + + private val userRepository by lazy { UserRepository.getInstance() } + private val friendshipRepository by lazy { FriendshipRepository.getInstance() } + private val storiesRepository by lazy { StoriesRepository.getInstance() } + private val mediaRepository by lazy { MediaRepository.getInstance() } + private val graphQLRepository by lazy { GraphQLRepository.getInstance() } + private val favoriteRepository by lazy { FavoriteRepository.getInstance(requireContext()) } + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } + + private val confirmDialogFragmentRequestCode = 100 + private val ppOptsDialogRequestCode = 101 + private val bioDialogRequestCode = 102 + private val translationDialogRequestCode = 103 + private val feedItemCallback: FeedAdapterV2.FeedItemCallback = object : FeedAdapterV2.FeedItemCallback { + override fun onPostClick(media: Media) { + openPostDialog(media, -1) + } + + override fun onProfilePicClick(media: Media) { + navigateToProfile(media.user?.username) + } + + override fun onNameClick(media: Media) { + navigateToProfile(media.user?.username) + } + + override fun onLocationClick(media: Media?) { + try { + val action = ProfileFragmentDirections.actionToLocation(media?.location?.pk ?: return) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "onLocationClick: ", e) + } + } + + override fun onMentionClick(mention: String?) { + navigateToProfile(mention?.trimAll() ?: return) + } + + override fun onHashtagClick(hashtag: String?) { + try { + val action = ProfileFragmentDirections.actionToHashtag(hashtag ?: return) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "onHashtagClick: ", e) + } + } + + override fun onCommentsClick(media: Media?) { + try { + val commentsAction = ProfileFragmentDirections.actionToComments( + media?.code ?: return, + media.pk ?: return, + media.user?.pk ?: return + ) + findNavController().navigate(commentsAction) + } catch (e: Exception) { + Log.e(TAG, "onCommentsClick: ", e) + } + } + + override fun onDownloadClick(media: Media?, childPosition: Int, popupLocation: View) { + DownloadUtils.showDownloadDialog(context ?: return, media ?: return, childPosition, popupLocation) + } + + override fun onEmailClick(emailId: String?) { + Utils.openEmailAddress(context ?: return, emailId ?: return) + } + + override fun onURLClick(url: String?) { + Utils.openURL(context ?: return, url ?: return) + } + + override fun onSliderClick(media: Media?, position: Int) { + openPostDialog(media ?: return, position) + } + } + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + binding.postsRecyclerView.endSelection() + } + } + private val multiSelectAction = PrimaryActionModeCallback( + R.menu.multi_select_download_menu, + object : PrimaryActionModeCallback.CallbacksHelper() { + override fun onDestroy(mode: ActionMode?) { + binding.postsRecyclerView.endSelection() + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + val item1 = item ?: return false + if (item1.itemId == R.id.action_download) { + val selectedMedia = this@ProfileFragment.selectedMedia ?: return false + val context = context ?: return false + DownloadUtils.download(context, selectedMedia) + binding.postsRecyclerView.endSelection() + return true + } + return false + } + } + ) + private val selectionModeCallback = object : FeedAdapterV2.SelectionModeCallback { + override fun onSelectionStart() { + if (!onBackPressedCallback.isEnabled) { + onBackPressedCallback.isEnabled = true + mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + } + if (actionMode == null) { + actionMode = mainActivity.startActionMode(multiSelectAction) + } + } + + override fun onSelectionChange(mediaSet: Set?) { + if (mediaSet == null) { + selectedMedia = null + return + } + val title = getString(R.string.number_selected, mediaSet.size) + actionMode?.title = title + selectedMedia = mediaSet.toList() + } + + override fun onSelectionEnd() { + if (onBackPressedCallback.isEnabled) { + onBackPressedCallback.isEnabled = false + onBackPressedCallback.remove() + } + (actionMode ?: return).finish() + actionMode = null + } + } + private val onProfilePicClickListener = View.OnClickListener { + val hasStories = viewModel.userStories.value?.data != null + if (!hasStories) { + showProfilePicDialog() + return@OnClickListener + } + val dialog = MultiOptionDialogFragment.newInstance( + ppOptsDialogRequestCode, + 0, + arrayListOf( + Option(getString(R.string.view_pfp), "profile_pic"), + Option(getString(R.string.show_stories), "show_stories") + ) + ) + dialog.show(childFragmentManager, MultiOptionDialogFragment::class.java.simpleName) + } + private val onFollowersClickListener = View.OnClickListener { + try { + val action = ProfileFragmentDirections.actionToFollowViewer( + viewModel.profile.value?.data?.pk ?: return@OnClickListener, + true, + viewModel.profile.value?.data?.username ?: return@OnClickListener + ) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "onFollowersClickListener: ", e) + } + } + private val onFollowingClickListener = View.OnClickListener { + try { + val action = ProfileFragmentDirections.actionToFollowViewer( + viewModel.profile.value?.data?.pk ?: return@OnClickListener, + false, + viewModel.profile.value?.data?.username ?: return@OnClickListener + ) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "onFollowersClickListener: ", e) + } + } + private val onEmailClickListener = OnEmailClickListener { + Utils.openEmailAddress(context ?: return@OnEmailClickListener, it.originalText.trimAll()) + } + private val onHashtagClickListener = OnHashtagClickListener { + try { + val actionToHashtag = ProfileFragmentDirections.actionToHashtag(it.originalText.trimAll()) + findNavController().navigate(actionToHashtag) + } catch (e: Exception) { + Log.e(TAG, "onHashtagClickListener: ", e) + } + } + private val onMentionClickListener = OnMentionClickListener { + navigateToProfile(it.originalText.trimAll()) + } + private val onURLClickListener = OnURLClickListener { + Utils.openURL(context ?: return@OnURLClickListener, it.originalText.trimAll()) + } + + @Suppress("UNCHECKED_CAST") + private val backStackSavedStateObserver = Observer { result -> + if (result == null) return@Observer + if ((result is RankedRecipient)) { + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + viewModel.shareDm(result) + } else if ((result is Set<*>)) { + try { + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + viewModel.shareDm(result as Set) + } catch (e: Exception) { + Log.e(TAG, "share: ", e) + } + } + // clear result + backStackSavedStateResultLiveData?.postValue(null) + } + + private fun openPostDialog(media: Media, position: Int) { + try { + val actionToPost = ProfileFragmentDirections.actionToPost(media, position) + findNavController().navigate(actionToPost) + } catch (e: Exception) { + Log.e(TAG, "openPostDialog: ", e) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mainActivity = requireActivity() as MainActivity + appStateViewModel = ViewModelProvider(mainActivity).get(AppStateViewModel::class.java) + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + val csrfToken = getCsrfTokenFromCookie(cookie) + val userId = getUserIdFromCookie(cookie) + val isLoggedIn = !csrfToken.isNullOrBlank() && userId != 0L && deviceUuid.isNotBlank() + viewModel = ViewModelProvider( + this, + ProfileFragmentViewModelFactory( + csrfToken, + deviceUuid, + userRepository, + friendshipRepository, + storiesRepository, + mediaRepository, + graphQLRepository, + favoriteRepository, + directMessagesRepository, + if (isLoggedIn) DirectMessagesManager else null, + this, + arguments + ) + ).get(ProfileFragmentViewModel::class.java) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentProfileBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + init() + } + + override fun onRefresh() { + viewModel.refresh() + binding.postsRecyclerView.refresh() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.profile_menu, menu) + blockMenuItem = menu.findItem(R.id.block) + restrictMenuItem = menu.findItem(R.id.restrict) + muteStoriesMenuItem = menu.findItem(R.id.mute_stories) + mutePostsMenuItem = menu.findItem(R.id.mute_posts) + chainingMenuItem = menu.findItem(R.id.chaining) + removeFollowerMenuItem = menu.findItem(R.id.remove_follower) + shareLinkMenuItem = menu.findItem(R.id.share_link) + shareDmMenuItem = menu.findItem(R.id.share_dm) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.layout -> showPostsLayoutPreferences() + R.id.restrict -> viewModel.restrictUser() + R.id.block -> viewModel.blockUser() + R.id.chaining -> navigateToChaining() + R.id.mute_stories -> viewModel.muteStories() + R.id.mute_posts -> viewModel.mutePosts() + R.id.remove_follower -> viewModel.removeFollower() + R.id.share_link -> shareProfileLink() + R.id.share_dm -> shareProfileViaDm() + } + return true + } + + override fun onResume() { + super.onResume() + mainActivity.setToolbar(binding.toolbar, this) + try { + val backStackEntry = NavHostFragment.findNavController(this).currentBackStackEntry + if (backStackEntry != null) { + backStackSavedStateResultLiveData = backStackEntry.savedStateHandle.getLiveData("result") + backStackSavedStateResultLiveData?.observe(viewLifecycleOwner, backStackSavedStateObserver) + } + mainActivity.supportActionBar?.title = viewModel.username.value + } catch (e: Exception) { + Log.e(TAG, "onResume: ", e) + } + } + + override fun onStop() { + super.onStop() + mainActivity.resetToolbar(this) + } + + override fun onDestroyView() { + super.onDestroyView() + setupPostsDone = false + } + + private fun shareProfileViaDm() { + try { + val actionToUserSearch = ProfileFragmentDirections.actionToUserSearch().apply { + title = getString(R.string.share) + actionLabel = getString(R.string.send) + showGroups = true + multiple = true + searchMode = UserSearchMode.RAVEN + } + findNavController().navigate(actionToUserSearch) + } catch (e: Exception) { + Log.e(TAG, "shareProfileViaDm: ", e) + } + } + + private fun shareProfileLink() { + val profile = viewModel.profile.value?.data ?: return + val sharingIntent = Intent(Intent.ACTION_SEND) + sharingIntent.type = "text/plain" + sharingIntent.putExtra(Intent.EXTRA_TEXT, "https://instagram.com/" + profile.username) + startActivity(Intent.createChooser(sharingIntent, null)) + } + + private fun navigateToChaining() { + viewModel.currentUser.value?.data ?: return + val profile = viewModel.profile.value?.data ?: return + try { + val actionToNotifications = ProfileFragmentDirections.actionToNotifications().apply { + type = "chaining" + targetId = profile.pk + } + findNavController().navigate(actionToNotifications) + } catch (e: Exception) { + Log.e(TAG, "navigateToChaining: ", e) + } + } + + private fun init() { + binding.swipeRefreshLayout.setOnRefreshListener(this) + disableDm = !isNavRootInCurrentTabs("direct_messages_nav_graph") + setupHighlights() + setupObservers() + } + + private fun setupObservers() { + appStateViewModel.currentUserLiveData.observe(viewLifecycleOwner, viewModel::setCurrentUser) + viewModel.isLoggedIn.observe(viewLifecycleOwner) { + // observe so that `isLoggedIn.value` is correct + Log.d(TAG, "setupObservers: $it") + } + viewModel.currentUserProfileActionLiveData.observe(viewLifecycleOwner) { + val (currentUserResource, profileResource) = it + if (currentUserResource.status == Resource.Status.ERROR || profileResource.status == Resource.Status.ERROR) { + context?.let { ctx -> Toast.makeText(ctx, R.string.error_loading_profile, Toast.LENGTH_LONG).show() } + return@observe + } + if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { + binding.swipeRefreshLayout.isRefreshing = true + return@observe + } + binding.swipeRefreshLayout.isRefreshing = false + val currentUser = currentUserResource.data + val profile = profileResource.data + val stateUsername = arguments?.getString("username") + setupOptionsMenuItems(currentUser, profile) + if (currentUser == null && profile == null && stateUsername.isNullOrBlank()) { + // default anonymous state, show default message + showDefaultMessage() + return@observe + } + if (profile == null && !stateUsername.isNullOrBlank()) { + context?.let { ctx -> Toast.makeText(ctx, R.string.error_loading_profile, Toast.LENGTH_LONG).show() } + return@observe + } + setupFavChip(profile, currentUser) + setupFavButton(currentUser, profile) + setupSavedButton(currentUser, profile) + setupTaggedButton(currentUser, profile) + setupLikedButton(currentUser, profile) + setupDMButton(currentUser, profile) + if (profile == null) return@observe + if (profile.isReallyPrivate(currentUser)) { + showPrivateAccountMessage() + return@observe + } + if (!setupPostsDone) { + setupPosts(profile, currentUser) + } + } + viewModel.username.observe(viewLifecycleOwner) { + mainActivity.supportActionBar?.title = it + mainActivity.supportActionBar?.subtitle = null + } + viewModel.profilePicUrl.observe(viewLifecycleOwner) { + val visibility = if (it.isNullOrBlank()) View.INVISIBLE else View.VISIBLE + binding.header.mainProfileImage.visibility = visibility + binding.header.mainProfileImage.setImageURI(if (it.isNullOrBlank()) null else it) + binding.header.mainProfileImage.setOnClickListener(if (it.isNullOrBlank()) null else onProfilePicClickListener) + } + viewModel.fullName.observe(viewLifecycleOwner) { binding.header.mainFullName.text = it ?: "" } + viewModel.biography.observe(viewLifecycleOwner, this::setupBiography) + viewModel.url.observe(viewLifecycleOwner, this::setupProfileURL) + viewModel.followersCount.observe(viewLifecycleOwner, this::setupFollowers) + viewModel.followingCount.observe(viewLifecycleOwner, this::setupFollowing) + viewModel.postCount.observe(viewLifecycleOwner, this::setupPostsCount) + viewModel.friendshipStatus.observe(viewLifecycleOwner) { + setupFollowButton(it) + setupMainStatus(it) + } + viewModel.isVerified.observe(viewLifecycleOwner) { + binding.header.isVerified.visibility = if (it == true) View.VISIBLE else View.GONE + } + viewModel.isPrivate.observe(viewLifecycleOwner) { + binding.header.isPrivate.visibility = if (it == true) View.VISIBLE else View.GONE + } + viewModel.isFavorite.observe(viewLifecycleOwner) { + if (!it) { + binding.header.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24) + binding.header.favChip.setText(R.string.add_to_favorites) + return@observe + } + binding.header.favChip.setChipIconResource(R.drawable.ic_star_check_24) + binding.header.favChip.setText(R.string.favorite_short) + } + viewModel.profileContext.observe(viewLifecycleOwner, this::setupProfileContext) + viewModel.userHighlights.observe(viewLifecycleOwner) { + binding.header.highlightsList.visibility = if (it.data.isNullOrEmpty()) View.GONE else View.VISIBLE + highlightsAdapter?.submitList(it.data) + } + viewModel.userStories.observe(viewLifecycleOwner) { + binding.header.mainProfileImage.setStoriesBorder(if (it.data == null) 0 else 1) + } + viewModel.eventLiveData.observe(viewLifecycleOwner) { + val event = it?.getContentIfNotHandled() ?: return@observe + when (event) { + ShowConfirmUnfollowDialog -> showConfirmUnfollowDialog() + is DMButtonState -> binding.header.btnDM.isEnabled = !event.disabled + is NavigateToThread -> mainActivity.navigateToThread(event.threadId, event.username) + is ShowTranslation -> showTranslationDialog(event.result) + } + } + } + + private fun showPrivateAccountMessage() { + binding.header.mainFollowers.isClickable = false + binding.header.mainFollowing.isClickable = false + binding.privatePage.visibility = VISIBLE + binding.privatePage.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + binding.privatePage1.setImageResource(R.drawable.lock) + binding.privatePage2.setText(R.string.priv_acc) + binding.privatePage.visibility = VISIBLE + binding.privatePage1.visibility = VISIBLE + binding.privatePage2.visibility = VISIBLE + binding.postsRecyclerView.visibility = GONE + binding.swipeRefreshLayout.isRefreshing = false + } + + private fun setupProfileContext(contextPair: Pair?>) { + val (profileContext, contextLinkList) = contextPair + if (profileContext == null || contextLinkList == null) { + binding.header.profileContext.visibility = GONE + binding.header.profileContext.clearOnMentionClickListeners() + return + } + var updatedProfileContext: String = profileContext + contextLinkList.forEachIndexed { i, link -> + if (link.username == null) return@forEachIndexed + updatedProfileContext = updatedProfileContext.substring(0, link.start + i) + "@" + updatedProfileContext.substring(link.start + i) + } + binding.header.profileContext.visibility = VISIBLE + binding.header.profileContext.text = updatedProfileContext + binding.header.profileContext.addOnMentionClickListener(onMentionClickListener) + } + + private fun setupProfileURL(url: String?) { + if (url.isNullOrBlank()) { + binding.header.mainUrl.visibility = GONE + binding.header.mainUrl.clearOnURLClickListeners() + binding.header.mainUrl.setOnLongClickListener(null) + return + } + binding.header.mainUrl.visibility = VISIBLE + binding.header.mainUrl.text = url + binding.header.mainUrl.addOnURLClickListener { Utils.openURL(context ?: return@addOnURLClickListener, it.originalText.trimAll()) } + binding.header.mainUrl.setOnLongClickListener { + Utils.copyText(context ?: return@setOnLongClickListener false, url.trimAll()) + return@setOnLongClickListener true + } + } + + private fun showTranslationDialog(result: String) { + val dialog = ConfirmDialogFragment.newInstance( + translationDialogRequestCode, + 0, + result, + R.string.ok, + 0, + 0 + ) + dialog.show(childFragmentManager, ConfirmDialogFragment::class.java.simpleName) + } + + private fun setupBiography(bio: String?) { + if (bio.isNullOrBlank()) { + binding.header.mainBiography.visibility = View.GONE + binding.header.mainBiography.clearAllAutoLinkListeners() + binding.header.mainBiography.setOnLongClickListener(null) + return + } + binding.header.mainBiography.visibility = View.VISIBLE + binding.header.mainBiography.text = bio + setCommonAutoLinkListeners(binding.header.mainBiography) + binding.header.mainBiography.setOnLongClickListener { + val isLoggedIn = viewModel.isLoggedIn.value ?: false + val options = arrayListOf(Option(getString(R.string.bio_copy), "copy")) + if (isLoggedIn) { + options.add(Option(getString(R.string.bio_translate), "translate")) + } + val dialog = MultiOptionDialogFragment.newInstance( + bioDialogRequestCode, + 0, + options + ) + dialog.show(childFragmentManager, MultiOptionDialogFragment::class.java.simpleName) + return@setOnLongClickListener true + } + } + + private fun setCommonAutoLinkListeners(textView: RamboTextViewV2) { + textView.addOnEmailClickListener(onEmailClickListener) + textView.addOnHashtagListener(onHashtagClickListener) + textView.addOnMentionClickListener(onMentionClickListener) + textView.addOnURLClickListener(onURLClickListener) + } + + private fun setupOptionsMenuItems(currentUser: User?, profile: User?) { + val isMe = currentUser?.pk == profile?.pk + if (profile == null || (currentUser != null && isMe)) { + hideAllOptionsMenuItems() + return + } + if (currentUser == null) { + hideAllOptionsMenuItems() + shareLinkMenuItem?.isVisible = profile.username.isNotBlank() + return + } + + blockMenuItem?.isVisible = true + blockMenuItem?.setTitle(if (profile.friendshipStatus?.blocking == true) R.string.unblock else R.string.block) + + restrictMenuItem?.isVisible = true + restrictMenuItem?.setTitle(if (profile.friendshipStatus?.isRestricted == true) R.string.unrestrict else R.string.restrict) + + muteStoriesMenuItem?.isVisible = true + muteStoriesMenuItem?.setTitle(if (profile.friendshipStatus?.isMutingReel == true) R.string.mute_stories else R.string.unmute_stories) + + mutePostsMenuItem?.isVisible = true + mutePostsMenuItem?.setTitle(if (profile.friendshipStatus?.muting == true) R.string.mute_posts else R.string.unmute_posts) + + chainingMenuItem?.isVisible = profile.hasChaining + removeFollowerMenuItem?.isVisible = profile.friendshipStatus?.followedBy ?: false + shareLinkMenuItem?.isVisible = profile.username.isNotBlank() + shareDmMenuItem?.isVisible = profile.pk != 0L + } + + private fun hideAllOptionsMenuItems() { + blockMenuItem?.isVisible = false + restrictMenuItem?.isVisible = false + muteStoriesMenuItem?.isVisible = false + mutePostsMenuItem?.isVisible = false + chainingMenuItem?.isVisible = false + removeFollowerMenuItem?.isVisible = false + shareLinkMenuItem?.isVisible = false + shareDmMenuItem?.isVisible = false + } + + private fun setupPostsCount(count: Long?) { + if (count == null) { + binding.header.mainPostCount.visibility = View.GONE + return + } + binding.header.mainPostCount.visibility = View.VISIBLE + binding.header.mainPostCount.text = getCountSpan(R.plurals.main_posts_count, abbreviate(count, null), count) + if (count >= 1000) { + TooltipCompat.setTooltipText(binding.header.mainPostCount, count.toString(10)) + } + } + + private fun setupFollowing(count: Long?) { + if (count == null) { + binding.header.mainFollowing.visibility = View.GONE + return + } + val abbreviate = abbreviate(count, null) + val span = SpannableStringBuilder(getString(R.string.main_posts_following, abbreviate)) + binding.header.mainFollowing.visibility = View.VISIBLE + binding.header.mainFollowing.text = getCountSpan(span, abbreviate) + if (count <= 0) { + binding.header.mainFollowing.setOnClickListener(null) + return + } + binding.header.mainFollowing.setOnClickListener(onFollowingClickListener) + if (count >= 1000) { + TooltipCompat.setTooltipText(binding.header.mainFollowing, count.toString(10)) + } + } + + private fun setupFollowers(count: Long?) { + if (count == null) { + binding.header.mainFollowers.visibility = View.GONE + return + } + binding.header.mainFollowers.visibility = View.VISIBLE + binding.header.mainFollowers.text = getCountSpan(R.plurals.main_posts_followers, abbreviate(count, null), count) + if (count <= 0) { + binding.header.mainFollowers.setOnClickListener(null) + return + } + binding.header.mainFollowers.setOnClickListener(onFollowersClickListener) + if (count >= 1000) { + TooltipCompat.setTooltipText(binding.header.mainFollowers, count.toString(10)) + } + } + + private fun setupDMButton(currentUser: User?, profile: User?) { + val visibility = if (disableDm || (currentUser != null && profile?.pk == currentUser.pk)) View.GONE else View.VISIBLE + binding.header.btnDM.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnDM.setOnClickListener(null) + return + } + binding.header.btnDM.setOnClickListener { viewModel.sendDm() } + } + + private fun setupLikedButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnLiked.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnLiked.setOnClickListener(null) + return + } + binding.header.btnLiked.setOnClickListener { + try { + val action = ProfileFragmentDirections.actionToSaved( + viewModel.profile.value?.data?.username ?: return@setOnClickListener, + viewModel.profile.value?.data?.pk ?: return@setOnClickListener, + PostItemType.LIKED + ) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "setupLikedButton: ", e) + } + } + } + + private fun setupTaggedButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnTagged.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnTagged.setOnClickListener(null) + return + } + binding.header.btnTagged.setOnClickListener { + try { + val action = ProfileFragmentDirections.actionToSaved( + viewModel.profile.value?.data?.username ?: return@setOnClickListener, + viewModel.profile.value?.data?.pk ?: return@setOnClickListener, + PostItemType.TAGGED + ) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "setupTaggedButton: ", e) + } + } + } + + private fun setupSavedButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnSaved.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnSaved.setOnClickListener(null) + return + } + binding.header.btnSaved.setOnClickListener { + try { + val action = ProfileFragmentDirections.actionToSavedCollections().apply { isSaving = false } + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "setupSavedButton: ", e) + } + } + } + + private fun setupFavButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk != currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnFollow.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnFollow.setOnClickListener(null) + return + } + binding.header.btnFollow.setOnClickListener { viewModel.toggleFollow(false) } + } + + private fun setupFavChip(profile: User?, currentUser: User?) { + val visibility = if (profile?.pk != currentUser?.pk) View.VISIBLE else View.GONE + binding.header.favChip.visibility = visibility + if (visibility == View.GONE) { + binding.header.favChip.setOnClickListener(null) + return + } + binding.header.favChip.setOnClickListener { viewModel.toggleFavorite() } + } + + private fun setupFollowButton(it: FriendshipStatus?) { + if (it == null) return + if (it.following) { + binding.header.btnFollow.setText(R.string.unfollow) + binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24) + return + } + if (it.outgoingRequest) { + binding.header.btnFollow.setText(R.string.cancel) + binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24) + return + } + binding.header.btnFollow.setText(R.string.follow) + binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_24) + } + + private fun setupMainStatus(it: FriendshipStatus?) { + if (it == null || (!it.following && !it.followedBy)) { + binding.header.mainStatus.visibility = View.GONE + return + } + binding.header.mainStatus.visibility = View.VISIBLE + if (it.following && it.followedBy) { + context?.let { ctx -> + binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.green_800) + binding.header.mainStatus.setText(R.string.status_mutual) + } + return + } + if (it.following) { + context?.let { ctx -> + binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.deep_orange_800) + binding.header.mainStatus.setText(R.string.status_following) + } + return + } + context?.let { ctx -> + binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.blue_800) + binding.header.mainStatus.setText(R.string.status_follower) + } + } + + private fun getCountSpan(pluralRes: Int, countString: String, count: Long): SpannableStringBuilder { + val span = SpannableStringBuilder(resources.getQuantityString(pluralRes, count.toInt(), countString)) + return getCountSpan(span, countString) + } + + private fun getCountSpan(span: SpannableStringBuilder, countString: String): SpannableStringBuilder { + span.setSpan(RelativeSizeSpan(1.2f), 0, countString.length, 0) + span.setSpan(StyleSpan(Typeface.BOLD), 0, countString.length, 0) + return span + } + + private fun showDefaultMessage() { + binding.header.root.visibility = GONE + binding.swipeRefreshLayout.visibility = GONE + binding.privatePage.visibility = VISIBLE + binding.privatePage1.visibility = VISIBLE + binding.privatePage2.visibility = VISIBLE + binding.privatePage1.setImageResource(R.drawable.ic_outline_info_24) + binding.privatePage2.setText(R.string.no_acc) + } + + private fun setupHighlights() { + val context = context ?: return + highlightsAdapter = HighlightsAdapter { model, position -> + val options = StoryViewerOptions.forHighlight(model.user!!.pk, "").apply { currentFeedStoryIndex = position } + val action = ProfileFragmentDirections.actionToStory(options) + NavHostFragment.findNavController(this).navigate(action) + } + binding.header.highlightsList.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + binding.header.highlightsList.adapter = highlightsAdapter + } + + private fun setupPosts(profile: User, currentUser: User?) { + binding.postsRecyclerView.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(ProfilePostFetchService(profile, currentUser != null)) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener { + AppExecutors.mainThread.execute { + binding.swipeRefreshLayout.isRefreshing = it + } + } + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init() + // binding.postsRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + // override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + // super.onScrolled(recyclerView, dx, dy) + // val canScrollVertically = recyclerView.canScrollVertically(-1) + // if (!canScrollVertically) { + // (binding.collapsingToolbarLayout.layoutParams as AppBarLayout.LayoutParams).scrollFlags = 0 + // } + // } + // }) + setupPostsDone = true + } + + private fun navigateToProfile(username: String?) { + try { + val username1 = username ?: return + val actionToProfile = ProfileFragmentDirections.actionToProfile().apply { this.username = username1 } + findNavController().navigate(actionToProfile) + } catch (e: Exception) { + Log.e(TAG, "navigateToProfile: ", e) + } + } + + private fun showConfirmUnfollowDialog() { + val isPrivate = viewModel.profile.value?.data?.isPrivate ?: return + val titleRes = if (isPrivate) R.string.priv_acc else 0 + val messageRes = if (isPrivate) R.string.priv_acc_confirm else R.string.are_you_sure + val dialog = ConfirmDialogFragment.newInstance( + confirmDialogFragmentRequestCode, + titleRes, + messageRes, + R.string.confirm, + R.string.cancel, + 0, + ) + dialog.show(childFragmentManager, ConfirmDialogFragment::class.java.simpleName) + } + + override fun onPositiveButtonClicked(requestCode: Int) { + when (requestCode) { + confirmDialogFragmentRequestCode -> { + viewModel.toggleFollow(true) + } + } + } + + override fun onNegativeButtonClicked(requestCode: Int) {} + + override fun onNeutralButtonClicked(requestCode: Int) {} + + override fun onSelect(requestCode: Int, result: String?) { + val r = result ?: return + when (requestCode) { + ppOptsDialogRequestCode -> onPpOptionSelect(r) + bioDialogRequestCode -> onBioOptionSelect(r) + } + } + + private fun onBioOptionSelect(result: String) { + when (result) { + "copy" -> Utils.copyText(context ?: return, viewModel.biography.value ?: return) + "translate" -> viewModel.translateBio() + } + } + + private fun onPpOptionSelect(result: String) { + when (result) { + "profile_pic" -> showProfilePicDialog() + "show_stories" -> { + try { + val action = ProfileFragmentDirections.actionToStory( + StoryViewerOptions.forUser( + viewModel.profile.value?.data?.pk ?: return, + viewModel.profile.value?.data?.username ?: return, + ) + ) + findNavController().navigate(action) + } catch (e: Exception) { + Log.e(TAG, "omPpOptionSelect: ", e) + } + } + } + } + + override fun onCancel(requestCode: Int) {} + + private fun showProfilePicDialog() { + val profile = viewModel.profile.value?.data ?: return + val fragment = ProfilePicDialogFragment.getInstance( + profile.pk, + profile.username, + profile.profilePicUrl ?: return + ) + val ft = childFragmentManager.beginTransaction() + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .add(fragment, ProfilePicDialogFragment::class.java.simpleName) + .commit() + } + + private fun showPostsLayoutPreferences() { + val fragment = PostsLayoutPreferencesDialogFragment(Constants.PREF_PROFILE_POSTS_LAYOUT) { preferences -> + layoutPreferences = preferences + Handler(Looper.getMainLooper()).postDelayed( + { binding.postsRecyclerView.layoutPreferences = preferences }, + 200 + ) + } + fragment.show(childFragmentManager, PostsLayoutPreferencesDialogFragment::class.java.simpleName) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java b/app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java new file mode 100644 index 0000000..d4703db --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java @@ -0,0 +1,197 @@ +package awais.instagrabber.fragments.search; + +import android.content.Context; +import android.os.Bundle; +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.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.adapters.SearchItemsAdapter; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; +import awais.instagrabber.viewmodels.SearchFragmentViewModel; + +public class SearchCategoryFragment extends Fragment { + private static final String TAG = SearchCategoryFragment.class.getSimpleName(); + private static final String ARG_TYPE = "type"; + + + @Nullable + private SwipeRefreshLayout swipeRefreshLayout; + @Nullable + private RecyclerView list; + private SearchFragmentViewModel viewModel; + private FavoriteType type; + private SearchItemsAdapter searchItemsAdapter; + @Nullable + private OnSearchItemClickListener onSearchItemClickListener; + private boolean skipViewRefresh; + private String prevQuery; + + @NonNull + public static SearchCategoryFragment newInstance(@NonNull final FavoriteType type) { + final SearchCategoryFragment fragment = new SearchCategoryFragment(); + final Bundle args = new Bundle(); + args.putSerializable(ARG_TYPE, type); + fragment.setArguments(args); + return fragment; + } + + public SearchCategoryFragment() {} + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + final Fragment parentFragment = getParentFragment(); + if (!(parentFragment instanceof OnSearchItemClickListener)) return; + onSearchItemClickListener = (OnSearchItemClickListener) parentFragment; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentActivity fragmentActivity = getActivity(); + if (fragmentActivity == null) return; + viewModel = new ViewModelProvider(fragmentActivity).get(SearchFragmentViewModel.class); + final Bundle args = getArguments(); + if (args == null) { + Log.e(TAG, "onCreate: arguments are null"); + return; + } + final Serializable typeSerializable = args.getSerializable(ARG_TYPE); + if (!(typeSerializable instanceof FavoriteType)) { + Log.e(TAG, "onCreate: type not a FavoriteType"); + return; + } + type = (FavoriteType) typeSerializable; + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + final Context context = getContext(); + if (context == null) return null; + skipViewRefresh = false; + if (swipeRefreshLayout != null) { + skipViewRefresh = true; + return swipeRefreshLayout; + } + swipeRefreshLayout = new SwipeRefreshLayout(context); + swipeRefreshLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + list = new RecyclerView(context); + list.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + swipeRefreshLayout.addView(list); + return swipeRefreshLayout; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (skipViewRefresh) return; + setupList(); + } + + @Override + public void onResume() { + super.onResume(); + // Log.d(TAG, "onResume: type: " + type); + setupObservers(); + final String currentQuery = viewModel.getQuery().getValue(); + if (prevQuery != null && currentQuery != null && !Objects.equals(prevQuery, currentQuery)) { + viewModel.search(currentQuery, type); + } + prevQuery = null; + } + + private void setupList() { + if (list == null || swipeRefreshLayout == null) return; + final Context context = getContext(); + if (context == null) return; + list.setLayoutManager(new LinearLayoutManager(context)); + searchItemsAdapter = new SearchItemsAdapter(onSearchItemClickListener); + list.setAdapter(searchItemsAdapter); + swipeRefreshLayout.setOnRefreshListener(() -> { + String currentQuery = viewModel.getQuery().getValue(); + if (currentQuery == null) currentQuery = ""; + viewModel.search(currentQuery, type); + }); + } + + private void setupObservers() { + viewModel.getQuery().observe(getViewLifecycleOwner(), q -> { + if (!isVisible() || Objects.equals(prevQuery, q)) return; + viewModel.search(q, type); + prevQuery = q; + }); + final LiveData>> resultsLiveData = getResultsLiveData(); + if (resultsLiveData != null) { + resultsLiveData.observe(getViewLifecycleOwner(), this::onResults); + } + } + + private void onResults(final Resource> listResource) { + if (listResource == null) return; + switch (listResource.status) { + case SUCCESS: + if (searchItemsAdapter != null) { + searchItemsAdapter.submitList(listResource.data); + } + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } + break; + case ERROR: + if (searchItemsAdapter != null) { + searchItemsAdapter.submitList(Collections.emptyList()); + } + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } + break; + case LOADING: + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(true); + } + break; + default: + break; + } + } + + @Nullable + private LiveData>> getResultsLiveData() { + switch (type) { + case TOP: + return viewModel.getTopResults(); + case USER: + return viewModel.getUserResults(); + case HASHTAG: + return viewModel.getHashtagResults(); + case LOCATION: + return viewModel.getLocationResults(); + } + return null; + } + + public interface OnSearchItemClickListener { + void onSearchItemClick(SearchItem searchItem); + + void onSearchItemDelete(SearchItem searchItem, FavoriteType type); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java b/app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java new file mode 100644 index 0000000..b4370e0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java @@ -0,0 +1,255 @@ +package awais.instagrabber.fragments.search; + +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.LinearLayoutCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; + +import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.tabs.TabLayoutMediator; + +import java.util.Arrays; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.SearchCategoryAdapter; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.databinding.FragmentSearchBinding; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; +import awais.instagrabber.viewmodels.SearchFragmentViewModel; + +import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_SEARCH_FOCUS_KEYBOARD; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SearchFragment extends Fragment implements SearchCategoryFragment.OnSearchItemClickListener { + private static final String TAG = SearchFragment.class.getSimpleName(); + private static final String QUERY = "query"; + + private FragmentSearchBinding binding; + private LinearLayoutCompat root; + private boolean shouldRefresh = true; + @Nullable + private EditText searchInput; + @Nullable + private MainActivity mainActivity; + private SearchFragmentViewModel viewModel; + + private final TextWatcherAdapter textWatcher = new TextWatcherAdapter() { + @Override + public void afterTextChanged(final Editable s) { + if (s == null) return; + viewModel.submitQuery(s.toString().trim()); + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentActivity fragmentActivity = getActivity(); + if (!(fragmentActivity instanceof MainActivity)) return; + mainActivity = (MainActivity) fragmentActivity; + viewModel = new ViewModelProvider(mainActivity).get(SearchFragmentViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentSearchBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + init(savedInstanceState); + shouldRefresh = false; + } + + @Override + public void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + final String current = viewModel.getQuery().getValue(); + if (TextUtils.isEmpty(current)) return; + outState.putString(QUERY, current); + } + + @Override + public void onPause() { + super.onPause(); + if (mainActivity != null) { + mainActivity.hideSearchView(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mainActivity != null) { + mainActivity.hideSearchView(); + } + if (searchInput != null) { + searchInput.removeTextChangedListener(textWatcher); + searchInput.setText(""); + } + } + + @Override + public void onResume() { + super.onResume(); + if (mainActivity != null) { + mainActivity.showSearchView(); + } + if (settingsHelper.getBoolean(PREF_SEARCH_FOCUS_KEYBOARD)) { + if (searchInput != null) { + searchInput.requestFocus(); + } + final InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) imm.showSoftInput(searchInput, InputMethodManager.SHOW_IMPLICIT); + } + } + + private void init(@Nullable final Bundle savedInstanceState) { + if (mainActivity == null) return; + searchInput = mainActivity.showSearchView().getEditText(); + setupObservers(); + setupViewPager(); + setupSearchInput(savedInstanceState); + } + + private void setupObservers() { + viewModel.getQuery().observe(getViewLifecycleOwner(), q -> {}); // need to observe, so that getQuery returns proper value + } + + private void setupSearchInput(@Nullable final Bundle savedInstanceState) { + if (searchInput == null) return; + searchInput.removeTextChangedListener(textWatcher); // make sure we add only 1 instance of textWatcher + searchInput.addTextChangedListener(textWatcher); + boolean triggerEmptyQuery = true; + if (savedInstanceState != null) { + final String savedQuery = savedInstanceState.getString(QUERY); + if (TextUtils.isEmpty(savedQuery)) return; + searchInput.setText(savedQuery); + triggerEmptyQuery = false; + } + if (settingsHelper.getBoolean(PREF_SEARCH_FOCUS_KEYBOARD)) { + searchInput.requestFocus(); + final InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) imm.showSoftInput(searchInput, InputMethodManager.SHOW_IMPLICIT); + } + if (triggerEmptyQuery) { + viewModel.submitQuery(""); + } + } + + private void setupViewPager() { + binding.pager.setSaveEnabled(false); + final List categories = Arrays.asList(FavoriteType.values()); + binding.pager.setAdapter(new SearchCategoryAdapter(this, categories)); + final TabLayoutMediator mediator = new TabLayoutMediator(binding.tabLayout, binding.pager, (tab, position) -> { + try { + final FavoriteType type = categories.get(position); + final int resId; + switch (type) { + case TOP: + resId = R.string.top; + break; + case USER: + resId = R.string.accounts; + break; + case HASHTAG: + resId = R.string.hashtags; + break; + case LOCATION: + resId = R.string.locations; + break; + default: + throw new IllegalStateException("Unexpected value: " + type); + } + tab.setText(resId); + } catch (Exception e) { + Log.e(TAG, "setupViewPager: ", e); + } + }); + mediator.attach(); + } + + @Override + public void onSearchItemClick(final SearchItem searchItem) { + if (searchItem == null) return; + final FavoriteType type = searchItem.getType(); + if (type == null) return; + try { + if (!searchItem.isFavorite()) { + viewModel.saveToRecentSearches(searchItem); // insert or update recent + } + final NavDirections action; + switch (type) { + case USER: + action = SearchFragmentDirections.actionToProfile().setUsername(searchItem.getUser().getUsername()); + break; + case HASHTAG: + action = SearchFragmentDirections.actionToHashtag(searchItem.getHashtag().getName()); + break; + case LOCATION: + action = SearchFragmentDirections.actionToLocation(searchItem.getPlace().getLocation().getPk()); + break; + default: + return; + } + NavHostFragment.findNavController(this).navigate(action); + } catch (Exception e) { + Log.e(TAG, "onSearchItemClick: ", e); + } + } + + @Override + public void onSearchItemDelete(final SearchItem searchItem, final FavoriteType type) { + final LiveData> liveData = viewModel.deleteRecentSearch(searchItem); + if (liveData == null) return; + liveData.observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(final Resource resource) { + if (resource == null) return; + switch (resource.status) { + case SUCCESS: + viewModel.search("", type); + viewModel.search("", FavoriteType.TOP); + liveData.removeObserver(this); + break; + case ERROR: + Snackbar.make(binding.getRoot(), R.string.error, Snackbar.LENGTH_SHORT).show(); + liveData.removeObserver(this); + break; + case LOADING: + default: + break; + } + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/AboutFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/AboutFragment.java new file mode 100644 index 0000000..5a48c04 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/AboutFragment.java @@ -0,0 +1,186 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; + +import awais.instagrabber.R; + +public class AboutFragment extends BasePreferencesFragment { + private static AppCompatTextView customPathTextView; + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + final PreferenceCategory generalCategory = new PreferenceCategory(context); + screen.addPreference(generalCategory); + generalCategory.setTitle(R.string.pref_category_general); + generalCategory.setIconSpaceReserved(false); + generalCategory.addPreference(getDocsPreference()); + generalCategory.addPreference(getRepoPreference()); + generalCategory.addPreference(getFeedbackPreference()); + + final PreferenceCategory licenseCategory = new PreferenceCategory(context); + screen.addPreference(licenseCategory); + licenseCategory.setTitle(R.string.about_category_license); + licenseCategory.setIconSpaceReserved(false); + licenseCategory.addPreference(getLicensePreference()); + licenseCategory.addPreference(getLiabilityPreference()); + + final PreferenceCategory thirdPartyCategory = new PreferenceCategory(context); + screen.addPreference(thirdPartyCategory); + thirdPartyCategory.setTitle(R.string.about_category_3pt); + thirdPartyCategory.setIconSpaceReserved(false); + // alphabetical order!!! + thirdPartyCategory.addPreference(get3ptPreference( + context, + "Apache Commons Imaging", + "Copyright 2007-2020 The Apache Software Foundation. Apache 2.0. This product includes software developed at The Apache Software Foundation (http://www.apache.org/).", + "https://commons.apache.org/proper/commons-imaging/" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "AutoLinkTextViewV2", + "Copyright (C) 2019 Arman Chatikyan. Apache 2.0.", + "https://github.com/armcha/AutoLinkTextViewV2" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "ExoPlayer", + "Copyright (C) 2016 The Android Open Source Project. Apache 2.0.", + "https://exoplayer.dev" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "Fresco", + "Copyright (c) Facebook, Inc. and its affiliates. MIT License.", + "https://frescolib.org" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "GPUImage", + "Copyright 2018 CyberAgent, Inc. Apache 2.0.", + "https://github.com/cats-oss/android-gpuimage" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "Material Design Icons", + "Copyright (C) 2014 Austin Andrews & Google LLC. Apache 2.0.", + "https://materialdesignicons.com" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "Process Phoenix", + "Copyright (C) 2015 Jake Wharton. Apache 2.0.", + "https://github.com/JakeWharton/ProcessPhoenix" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "Retrofit", + "Copyright 2013 Square, Inc. Apache 2.0.", + "https://square.github.io/retrofit/" + )); + thirdPartyCategory.addPreference(get3ptPreference( + context, + "uCrop", + "Copyright 2017 Yalantis. Apache 2.0.", + "https://github.com/Yalantis/uCrop" + )); + } + + private Preference getDocsPreference() { + final Context context = getContext(); + if (context == null) return null; + final Preference preference = new Preference(context); + preference.setTitle(R.string.about_documentation); + preference.setSummary(R.string.about_documentation_summary); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(p -> { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://barinsta.austinhuang.me")); + startActivity(intent); + return true; + }); + return preference; + } + + private Preference getRepoPreference() { + final Context context = getContext(); + if (context == null) return null; + final Preference preference = new Preference(context); + preference.setTitle(R.string.about_repository); + preference.setSummary(R.string.about_repository_summary); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(p -> { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://github.com/austinhuang0131/barinsta")); + startActivity(intent); + return true; + }); + return preference; + } + + private Preference getFeedbackPreference() { + final Context context = getContext(); + if (context == null) return null; + final Preference preference = new Preference(context); + preference.setTitle(R.string.about_feedback); + preference.setSummary(R.string.about_feedback_summary); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(p -> { + final Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("message/rfc822") + .putExtra(Intent.EXTRA_EMAIL, getString(R.string.about_feedback_summary)) + .putExtra(Intent.EXTRA_TEXT, "Please note that your email address and the entire content will be published onto GitHub issues. If you do not wish to do that, use other contact methods instead."); + if (intent.resolveActivity(context.getPackageManager()) != null) startActivity(intent); + return true; + }); + return preference; + } + + private Preference getLicensePreference() { + final Context context = getContext(); + if (context == null) return null; + final Preference preference = new Preference(context); + preference.setSummary(R.string.license); + preference.setEnabled(false); + preference.setIcon(R.drawable.ic_outline_info_24); + preference.setIconSpaceReserved(true); + return preference; + } + + private Preference getLiabilityPreference() { + final Context context = getContext(); + if (context == null) return null; + final Preference preference = new Preference(context); + preference.setSummary(R.string.liability); + preference.setEnabled(false); + preference.setIcon(R.drawable.ic_warning); + preference.setIconSpaceReserved(true); + return preference; + } + + private Preference get3ptPreference(@NonNull final Context context, + final String title, + final String summary, + final String url) { + final Preference preference = new Preference(context); + preference.setTitle(title); + preference.setSummary(summary); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(p -> { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(url)); + startActivity(intent); + return true; + }); + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/BackupPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/BackupPreferencesFragment.java new file mode 100644 index 0000000..56e3a87 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/BackupPreferencesFragment.java @@ -0,0 +1,151 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; +import android.os.Build; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreferenceCompat; + +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.CreateBackupDialogFragment; +import awais.instagrabber.dialogs.RestoreBackupDialogFragment; + +public class BackupPreferencesFragment extends BasePreferencesFragment { + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) { + return; + } + if (Build.VERSION.SDK_INT >= 23) { + final PreferenceCategory autoCategory = new PreferenceCategory(context); + screen.addPreference(autoCategory); + autoCategory.setTitle(R.string.auto_backup); + autoCategory.addPreference(getAboutPreference(context, true)); + autoCategory.addPreference(getWarningPreference(context, true)); + autoCategory.addPreference(getAutoBackupPreference(context)); + } + final PreferenceCategory manualCategory = new PreferenceCategory(context); + screen.addPreference(manualCategory); + manualCategory.setTitle(R.string.manual_backup); + manualCategory.addPreference(getAboutPreference(context, false)); + manualCategory.addPreference(getWarningPreference(context, false)); + manualCategory.addPreference(getCreatePreference(context)); + manualCategory.addPreference(getRestorePreference(context)); + } + + private Preference getAboutPreference(@NonNull final Context context, + @NonNull final boolean auto) { + final Preference preference = new Preference(context); + preference.setSummary(auto ? R.string.auto_backup_summary : R.string.backup_summary); + preference.setEnabled(false); + preference.setIcon(R.drawable.ic_outline_info_24); + preference.setIconSpaceReserved(true); + return preference; + } + + private Preference getWarningPreference(@NonNull final Context context, + @NonNull final boolean auto) { + final Preference preference = new Preference(context); + preference.setSummary(auto ? R.string.auto_backup_warning : R.string.backup_warning); + preference.setEnabled(false); + preference.setIcon(R.drawable.ic_warning); + preference.setIconSpaceReserved(true); + return preference; + } + + private Preference getAutoBackupPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.PREF_AUTO_BACKUP_ENABLED); + preference.setTitle(R.string.auto_backup_setting); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getCreatePreference(@NonNull final Context context) { + final Preference preference = new Preference(context); + preference.setTitle(R.string.create_backup); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(preference1 -> { + final FragmentManager fragmentManager = getParentFragmentManager(); + final CreateBackupDialogFragment fragment = new CreateBackupDialogFragment(result -> { + final View view = getView(); + if (view != null) { + Snackbar.make(view, + result ? R.string.dialog_export_success + : R.string.dialog_export_failed, + BaseTransientBottomBar.LENGTH_LONG) + .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) + .setAction(R.string.ok, v -> {}) + .show(); + return; + } + Toast.makeText(context, + result ? R.string.dialog_export_success + : R.string.dialog_export_failed, + Toast.LENGTH_LONG) + .show(); + }); + final FragmentTransaction ft = fragmentManager.beginTransaction(); + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .add(fragment, "createBackup") + .commit(); + return true; + }); + return preference; + } + + private Preference getRestorePreference(@NonNull final Context context) { + final Preference preference = new Preference(context); + preference.setTitle(R.string.restore_backup); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(preference1 -> { + final FragmentManager fragmentManager = getParentFragmentManager(); + final RestoreBackupDialogFragment fragment = new RestoreBackupDialogFragment(result -> { + final View view = getView(); + if (view != null) { + Snackbar.make(view, + result ? R.string.dialog_import_success + : R.string.dialog_import_failed, + BaseTransientBottomBar.LENGTH_LONG) + .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) + .setAction(R.string.ok, v -> {}) + .addCallback(new BaseTransientBottomBar.BaseCallback() { + @Override + public void onDismissed(final Snackbar transientBottomBar, final int event) { + recreateActivity(result); + } + }) + .show(); + return; + } + recreateActivity(result); + }); + final FragmentTransaction ft = fragmentManager.beginTransaction(); + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .add(fragment, "restoreBackup") + .commit(); + return true; + }); + return preference; + } + + private void recreateActivity(final boolean result) { + if (!result) return; + final FragmentActivity activity = getActivity(); + if (activity == null) return; + activity.recreate(); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/BasePreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/BasePreferencesFragment.java new file mode 100644 index 0000000..3e7f304 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/BasePreferencesFragment.java @@ -0,0 +1,57 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.LocaleUtils; + +public abstract class BasePreferencesFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { + private boolean shouldRecreate = false; + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + final PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setSharedPreferencesName(Constants.SHARED_PREFERENCES_NAME); + preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(this); + final Context context = getContext(); + if (context == null) return; + final PreferenceScreen screen = preferenceManager.createPreferenceScreen(context); + setupPreferenceScreen(screen); + setPreferenceScreen(screen); + } + + abstract void setupPreferenceScreen(PreferenceScreen screen); + + protected void shouldRecreate() { + this.shouldRecreate = true; + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { + if (!shouldRecreate) return; + final MainActivity activity = (MainActivity) getActivity(); + if (activity == null) return; + if (key.equals(PreferenceKeys.APP_LANGUAGE)) { + LocaleUtils.setLocale(activity.getBaseContext()); + } + shouldRecreate = false; + activity.recreate(); + } + + @NonNull + protected Preference getDivider(final Context context) { + final Preference divider = new Preference(context); + divider.setLayoutResource(R.layout.item_pref_divider); + return divider; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/DMPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/DMPreferencesFragment.java new file mode 100644 index 0000000..2633bb7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/DMPreferencesFragment.java @@ -0,0 +1,200 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; +import android.content.Intent; +import android.text.Editable; +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; +import androidx.preference.PreferenceViewHolder; + +import java.util.Objects; + +import awais.instagrabber.R; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.databinding.PrefAutoRefreshDmFreqBinding; +import awais.instagrabber.services.DMSyncAlarmReceiver; +import awais.instagrabber.services.DMSyncService; +import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.utils.TextUtils; + +import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER; +import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class DMPreferencesFragment extends BasePreferencesFragment { + private static final String TAG = DMPreferencesFragment.class.getSimpleName(); + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + screen.addPreference(getMarkDMSeenPreference(context)); + // screen.addPreference(getAutoRefreshDMPreference(context)); + // screen.addPreference(getAutoRefreshDMFreqPreference(context)); + } + + private Preference getMarkDMSeenPreference(@NonNull final Context context) { + return PreferenceHelper.getSwitchPreference( + context, + PreferenceKeys.DM_MARK_AS_SEEN, + R.string.dm_mark_as_seen_setting, + R.string.dm_mark_as_seen_setting_summary, + false, + null + ); + } + + private Preference getAutoRefreshDMPreference(@NonNull final Context context) { + return PreferenceHelper.getSwitchPreference( + context, + PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH, + R.string.enable_dm_auto_refesh, + -1, + false, + (preference, newValue) -> { + if (!(newValue instanceof Boolean)) return false; + final boolean enabled = (Boolean) newValue; + if (enabled) { + DMSyncAlarmReceiver.setAlarm(context); + return true; + } + DMSyncAlarmReceiver.cancelAlarm(context); + try { + final Context applicationContext = context.getApplicationContext(); + applicationContext.stopService(new Intent(applicationContext, DMSyncService.class)); + } catch (Exception e) { + Log.e(TAG, "getAutoRefreshDMPreference: ", e); + } + return true; + } + ); + } + + private Preference getAutoRefreshDMFreqPreference(@NonNull final Context context) { + return new AutoRefreshDMFrePreference(context); + } + + public static class AutoRefreshDMFrePreference extends Preference { + private static final String TAG = AutoRefreshDMFrePreference.class.getSimpleName(); + private static final String DEBOUNCE_KEY = "dm_sync_service_update"; + public static final int INTERVAL = 2000; + + private final Debouncer.Callback changeCallback; + + private Debouncer serviceUpdateDebouncer; + private PrefAutoRefreshDmFreqBinding binding; + + public AutoRefreshDMFrePreference(final Context context) { + super(context); + setLayoutResource(R.layout.pref_auto_refresh_dm_freq); + // setKey(key); + setIconSpaceReserved(false); + changeCallback = new Debouncer.Callback() { + @Override + public void call(final String key) { + DMSyncAlarmReceiver.setAlarm(context); + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "onError: ", t); + } + }; + serviceUpdateDebouncer = new Debouncer<>(changeCallback, INTERVAL); + } + + @Override + public void onDependencyChanged(final Preference dependency, final boolean disableDependent) { + // super.onDependencyChanged(dependency, disableDependent); + if (binding == null) return; + binding.startText.setEnabled(!disableDependent); + binding.freqNum.setEnabled(!disableDependent); + binding.freqUnit.setEnabled(!disableDependent); + if (disableDependent) { + serviceUpdateDebouncer.terminate(); + return; + } + serviceUpdateDebouncer = new Debouncer<>(changeCallback, INTERVAL); + } + + @Override + public void onBindViewHolder(final PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + setDependency(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH); + binding = PrefAutoRefreshDmFreqBinding.bind(holder.itemView); + final Context context = getContext(); + if (context == null) return; + setupUnitSpinner(context); + setupNumberEditText(context); + } + + private void setupUnitSpinner(final Context context) { + final ArrayAdapter adapter = ArrayAdapter.createFromResource(context, + R.array.dm_auto_refresh_freq_unit_labels, + android.R.layout.simple_spinner_item); + final String[] values = context.getResources().getStringArray(R.array.dm_auto_refresh_freq_units); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.freqUnit.setAdapter(adapter); + + String unit = settingsHelper.getString(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT); + if (TextUtils.isEmpty(unit)) { + unit = "secs"; + } + int position = 0; + for (int i = 0; i < values.length; i++) { + if (Objects.equals(unit, values[i])) { + position = i; + break; + } + } + binding.freqUnit.setSelection(position); + binding.freqUnit.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, final int position, final long id) { + settingsHelper.putString(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT, values[position]); + if (!isEnabled()) { + serviceUpdateDebouncer.terminate(); + return; + } + serviceUpdateDebouncer.call(DEBOUNCE_KEY); + } + + @Override + public void onNothingSelected(final AdapterView parent) {} + }); + } + + private void setupNumberEditText(final Context context) { + int currentValue = settingsHelper.getInteger(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER); + if (currentValue <= 0) { + currentValue = 30; + } + binding.freqNum.setText(String.valueOf(currentValue)); + binding.freqNum.addTextChangedListener(new TextWatcherAdapter() { + + @Override + public void afterTextChanged(final Editable s) { + if (TextUtils.isEmpty(s)) return; + try { + final int value = Integer.parseInt(s.toString()); + if (value <= 0) return; + settingsHelper.putInteger(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER, value); + if (!isEnabled()) { + serviceUpdateDebouncer.terminate(); + return; + } + serviceUpdateDebouncer.call(DEBOUNCE_KEY); + } catch (Exception e) { + Log.e(TAG, "afterTextChanged: ", e); + } + } + }); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/DownloadsPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/DownloadsPreferencesFragment.java new file mode 100644 index 0000000..a5d2024 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/DownloadsPreferencesFragment.java @@ -0,0 +1,147 @@ +package awais.instagrabber.fragments.settings; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreferenceCompat; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.ConfirmDialogFragment; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static android.app.Activity.RESULT_OK; +import static awais.instagrabber.activities.DirectorySelectActivity.SELECT_DIR_REQUEST_CODE; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class DownloadsPreferencesFragment extends BasePreferencesFragment { + private static final String TAG = DownloadsPreferencesFragment.class.getSimpleName(); + private Preference dirPreference; + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + screen.addPreference(getDownloadUserFolderPreference(context)); + screen.addPreference(getSaveToCustomFolderPreference(context)); + screen.addPreference(getPrependUsernameToFilenamePreference(context)); + } + + private Preference getDownloadUserFolderPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.DOWNLOAD_USER_FOLDER); + preference.setTitle(R.string.download_user_folder); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getSaveToCustomFolderPreference(@NonNull final Context context) { + dirPreference = new Preference(context); + dirPreference.setIconSpaceReserved(false); + dirPreference.setTitle(R.string.barinsta_folder); + final String currentValue = settingsHelper.getString(PreferenceKeys.PREF_BARINSTA_DIR_URI); + if (TextUtils.isEmpty(currentValue)) dirPreference.setSummary(""); + else { + String path; + try { + path = URLDecoder.decode(currentValue, StandardCharsets.UTF_8.toString()); + } catch (UnsupportedEncodingException e) { + path = currentValue; + } + dirPreference.setSummary(path); + } + dirPreference.setOnPreferenceClickListener(p -> { + openDirectoryChooser(DownloadUtils.getRootDirUri()); + return true; + }); + return dirPreference; + } + + private void openDirectoryChooser(final Uri initialUri) { + final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && initialUri != null) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri); + } + try { + startActivityForResult(intent, SELECT_DIR_REQUEST_CODE); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "openDirectoryChooser: ", e); + showErrorDialog(getString(R.string.no_directory_picker_activity)); + } catch (Exception e) { + Log.e(TAG, "openDirectoryChooser: ", e); + } + } + + @SuppressLint("StringFormatInvalid") + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + if (requestCode != SELECT_DIR_REQUEST_CODE) return; + if (resultCode != RESULT_OK) return; + if (data == null || data.getData() == null) return; + final Context context = getContext(); + if (context == null) return; + AppExecutors.INSTANCE.getMainThread().execute(() -> { + try { + Utils.setupSelectedDir(context, data); + String path; + try { + path = URLDecoder.decode(data.getData().toString(), StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + path = data.getData().toString(); + } + dirPreference.setSummary(path); + } catch (Exception e) { + // Should not come to this point. + // If it does, we have to show this error to the user so that they can report it. + try (final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw)) { + e.printStackTrace(pw); + showErrorDialog("com.android.externalstorage.documents".equals(data.getData().getAuthority()) + ? "Please report this error to the developers:\n\n" + sw.toString() + : getString(R.string.dir_select_no_download_folder, data.getData().getAuthority())); + } catch (IOException ioException) { + Log.e(TAG, "onActivityResult: ", ioException); + } + } + }, 500); + } + + private void showErrorDialog(final String message) { + final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( + 123, + R.string.error, + message, + R.string.ok, + 0, + 0 + ); + dialogFragment.show(getChildFragmentManager(), ConfirmDialogFragment.class.getSimpleName()); + } + + private Preference getPrependUsernameToFilenamePreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME); + preference.setTitle(R.string.download_prepend_username); + preference.setIconSpaceReserved(false); + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/GeneralPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/GeneralPreferencesFragment.java new file mode 100644 index 0000000..52e7fdd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/GeneralPreferencesFragment.java @@ -0,0 +1,140 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreferenceCompat; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.ConfirmDialogFragment; +import awais.instagrabber.dialogs.TabOrderPreferenceDialogFragment; +import awais.instagrabber.models.Tab; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.NavigationHelperKt; +import awais.instagrabber.utils.TextUtils; +import kotlin.Pair; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class GeneralPreferencesFragment extends BasePreferencesFragment implements TabOrderPreferenceDialogFragment.Callback { + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + final String cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + if (isLoggedIn) { + screen.addPreference(getDefaultTabPreference(context)); + screen.addPreference(getTabOrderPreference(context)); + } + screen.addPreference(getDisableScreenTransitionsPreference(context)); + screen.addPreference(getUpdateCheckPreference(context)); + screen.addPreference(getFlagSecurePreference(context)); + screen.addPreference(getSearchFocusPreference(context)); + final List preferences = FlavorSettings + .getInstance() + .getPreferences( + context, + getChildFragmentManager(), + SettingCategory.GENERAL + ); + for (final Preference preference : preferences) { + screen.addPreference(preference); + } + } + + private Preference getDefaultTabPreference(@NonNull final Context context) { + final ListPreference preference = new ListPreference(context); + preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + final Pair, List> listPair = NavigationHelperKt.getLoggedInNavTabs(context); + final List tabs = listPair.getFirst(); + final String[] titles = tabs.stream() + .map(Tab::getTitle) + .toArray(String[]::new); + final String[] navGraphFileNames = tabs.stream() + .map(tab -> NavigationHelperKt.getNavGraphNameForNavRootId(tab.getNavigationRootId())) + .toArray(String[]::new); + preference.setKey(Constants.DEFAULT_TAB); + preference.setTitle(R.string.pref_start_screen); + preference.setDialogTitle(R.string.pref_start_screen); + preference.setEntries(titles); + preference.setEntryValues(navGraphFileNames); + preference.setIconSpaceReserved(false); + return preference; + } + + @NonNull + private Preference getTabOrderPreference(@NonNull final Context context) { + final Preference preference = new Preference(context); + preference.setTitle(R.string.tab_order); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(preference1 -> { + final TabOrderPreferenceDialogFragment dialogFragment = TabOrderPreferenceDialogFragment.newInstance(); + dialogFragment.show(getChildFragmentManager(), "tab_order_dialog"); + return true; + }); + return preference; + } + + private Preference getDisableScreenTransitionsPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.PREF_DISABLE_SCREEN_TRANSITIONS); + preference.setTitle(R.string.disable_screen_transitions); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getUpdateCheckPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.CHECK_UPDATES); + preference.setTitle(R.string.update_check); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getFlagSecurePreference(@NonNull final Context context) { + return PreferenceHelper.getSwitchPreference( + context, + PreferenceKeys.FLAG_SECURE, + R.string.flag_secure, + -1, + false, + (preference, newValue) -> { + shouldRecreate(); + return true; + }); + } + + private Preference getSearchFocusPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.PREF_SEARCH_FOCUS_KEYBOARD); + preference.setTitle(R.string.pref_search_focus_keyboard); + preference.setIconSpaceReserved(false); + return preference; + } + + @Override + public void onSave(final boolean orderHasChanged) { + if (!orderHasChanged) return; + final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( + 111, + 0, + R.string.tab_order_start_next_launch, + R.string.ok, + 0, + 0); + dialogFragment.show(getChildFragmentManager(), "tab_order_set_dialog"); + } + + @Override + public void onCancel() { + + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/IFlavorSettings.java b/app/src/main/java/awais/instagrabber/fragments/settings/IFlavorSettings.java new file mode 100644 index 0000000..4b0f1c5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/IFlavorSettings.java @@ -0,0 +1,14 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.fragment.app.FragmentManager; +import androidx.preference.Preference; + +import java.util.List; + +public interface IFlavorSettings { + List getPreferences(Context context, + FragmentManager childFragmentManager, + SettingCategory settingCategory); +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/LocalePreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/LocalePreferencesFragment.java new file mode 100644 index 0000000..f789cd6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/LocalePreferencesFragment.java @@ -0,0 +1,90 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import java.time.format.DateTimeFormatter; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.TimeSettingsDialog; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.LocaleUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.UserAgentUtils; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class LocalePreferencesFragment extends BasePreferencesFragment { + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + screen.addPreference(getLanguagePreference(context)); + screen.addPreference(getPostTimeFormatPreference(context)); + } + + private Preference getLanguagePreference(@NonNull final Context context) { + final ListPreference preference = new ListPreference(context); + preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + final int length = getResources().getStringArray(R.array.languages).length; + final String[] values = new String[length]; + for (int i = 0; i < length; i++) { + values[i] = String.valueOf(i); + } + preference.setKey(PreferenceKeys.APP_LANGUAGE); + preference.setTitle(R.string.select_language); + preference.setDialogTitle(R.string.select_language); + preference.setEntries(R.array.languages); + preference.setIconSpaceReserved(false); + preference.setEntryValues(values); + preference.setOnPreferenceChangeListener((preference1, newValue) -> { + shouldRecreate(); + final int appUaCode = settingsHelper.getInteger(Constants.APP_UA_CODE); + final String appUa = UserAgentUtils.generateAppUA(appUaCode, LocaleUtils.getCurrentLocale().getLanguage()); + settingsHelper.putString(Constants.APP_UA, appUa); + return true; + }); + return preference; + } + + private Preference getPostTimeFormatPreference(@NonNull final Context context) { + final Preference preference = new Preference(context); + preference.setTitle(R.string.time_settings); + preference.setSummary(TextUtils.nowToString()); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(preference1 -> { + new TimeSettingsDialog( + settingsHelper.getBoolean(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED), + settingsHelper.getString(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT), + settingsHelper.getString(PreferenceKeys.DATE_TIME_SELECTION), + settingsHelper.getBoolean(PreferenceKeys.SWAP_DATE_TIME_FORMAT_ENABLED), + (isCustomFormat, + spTimeFormatSelectedItemPosition, + spSeparatorSelectedItemPosition, + spDateFormatSelectedItemPosition, + selectedFormat, + swapDateTime) -> { + settingsHelper.putBoolean(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED, isCustomFormat); + settingsHelper.putBoolean(PreferenceKeys.SWAP_DATE_TIME_FORMAT_ENABLED, swapDateTime); + if (isCustomFormat) { + settingsHelper.putString(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT, selectedFormat); + } else { + final String formatSelectionUpdated = spTimeFormatSelectedItemPosition + ";" + + spSeparatorSelectedItemPosition + ';' + + spDateFormatSelectedItemPosition; // time;separator;date + settingsHelper.putString(PreferenceKeys.DATE_TIME_FORMAT, selectedFormat); + settingsHelper.putString(PreferenceKeys.DATE_TIME_SELECTION, formatSelectionUpdated); + } + TextUtils.setFormatter(DateTimeFormatter.ofPattern(selectedFormat, LocaleUtils.getCurrentLocale())); + preference.setSummary(TextUtils.nowToString()); + } + ).show(getParentFragmentManager(), null); + return true; + }); + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java new file mode 100644 index 0000000..40f648a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java @@ -0,0 +1,462 @@ +package awais.instagrabber.fragments.settings; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentManager; +import androidx.navigation.NavController; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; +import androidx.preference.PreferenceViewHolder; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.BuildConfig; +import awais.instagrabber.R; +import awais.instagrabber.activities.Login; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.databinding.PrefAccountSwitcherBinding; +import awais.instagrabber.db.repositories.AccountRepository; +import awais.instagrabber.dialogs.AccountSwitcherDialogFragment; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.FlavorTown; +import awais.instagrabber.utils.NavigationHelperKt; +import awais.instagrabber.utils.ProcessPhoenix; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.UserRepository; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class MorePreferencesFragment extends BasePreferencesFragment { + private static final String TAG = "MorePreferencesFragment"; + + private AccountRepository accountRepository; + + public MorePreferencesFragment() { + } + + @Override + public RecyclerView onCreateRecyclerView(final LayoutInflater inflater, final ViewGroup parent, final Bundle savedInstanceState) { + final RecyclerView recyclerView = super.onCreateRecyclerView(inflater, parent, savedInstanceState); + final Context context = getContext(); + if (recyclerView != null && context != null) { + recyclerView.setClipToPadding(false); + recyclerView.setPadding(recyclerView.getPaddingLeft(), + recyclerView.getPaddingTop(), + recyclerView.getPaddingRight(), + Utils.getActionBarHeight(context)); + } + return recyclerView; + } + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final String cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + final MainActivity activity = (MainActivity) getActivity(); + // screen.addPreference(new MoreHeaderPreference(getContext())); + final Context context = getContext(); + if (context == null) return; + accountRepository = AccountRepository.Companion.getInstance(context); + final PreferenceCategory accountCategory = new PreferenceCategory(context); + accountCategory.setTitle(R.string.account); + accountCategory.setIconSpaceReserved(false); + screen.addPreference(accountCategory); + if (isLoggedIn) { + accountCategory.setSummary(R.string.account_hint); + accountCategory.addPreference(getAccountSwitcherPreference(cookie, context)); + accountCategory.addPreference(getPreference(R.string.logout, R.string.logout_summary, R.drawable.ic_logout_24, preference -> { + final Context context1 = getContext(); + if (context1 == null) return false; + CookieUtils.setupCookies("LOGOUT"); + // shouldRecreate(); + Toast.makeText(context1, R.string.logout_success, Toast.LENGTH_SHORT).show(); + settingsHelper.putString(Constants.COOKIE, ""); + AppExecutors.INSTANCE.getMainThread().execute(() -> ProcessPhoenix.triggerRebirth(context1), 200); + return true; + })); + } + accountRepository.getAllAccounts( + CoroutineUtilsKt.getContinuation((accounts, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.d(TAG, "getAllAccounts", throwable); + if (!isLoggedIn) { + // Need to show something to trigger login activity + accountCategory.addPreference(getPreference(R.string.add_account, R.drawable.ic_add, preference -> { + startActivityForResult(new Intent(getContext(), Login.class), Constants.LOGIN_RESULT_CODE); + return true; + })); + } + return; + } + if (!isLoggedIn) { + if (accounts.size() > 0) { + final Context context1 = getContext(); + final AccountSwitcherPreference preference = getAccountSwitcherPreference(null, context1); + if (preference == null) return; + accountCategory.addPreference(preference); + } + // Need to show something to trigger login activity + final Preference preference1 = getPreference(R.string.add_account, R.drawable.ic_add, preference -> { + final Context context1 = getContext(); + if (context1 == null) return false; + startActivityForResult(new Intent(context1, Login.class), Constants.LOGIN_RESULT_CODE); + return true; + }); + if (preference1 == null) return; + accountCategory.addPreference(preference1); + } + if (accounts.size() > 0) { + final Preference preference1 = getPreference( + R.string.remove_all_acc, + null, + R.drawable.ic_account_multiple_remove_24, + preference -> { + if (getContext() == null) return false; + new AlertDialog.Builder(getContext()) + .setTitle(R.string.logout) + .setMessage(R.string.remove_all_acc_warning) + .setPositiveButton(R.string.yes, (dialog, which) -> { + final Context context1 = getContext(); + if (context1 == null) return; + CookieUtils.removeAllAccounts( + context1, + CoroutineUtilsKt.getContinuation( + (unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + return; + } + final Context context2 = getContext(); + if (context2 == null) return; + Toast.makeText(context2, R.string.logout_success, Toast.LENGTH_SHORT).show(); + settingsHelper.putString(Constants.COOKIE, ""); + AppExecutors.INSTANCE + .getMainThread() + .execute(() -> ProcessPhoenix.triggerRebirth(context1), 200); + }), + Dispatchers.getIO() + ) + ); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + }); + if (preference1 == null) return; + accountCategory.addPreference(preference1); + } + }), Dispatchers.getIO()) + ); + + // final PreferenceCategory generalCategory = new PreferenceCategory(context); + // generalCategory.setTitle(R.string.pref_category_general); + // generalCategory.setIconSpaceReserved(false); + // screen.addPreference(generalCategory); + screen.addPreference(getDivider(context)); + final NavController navController = NavHostFragment.findNavController(this); + if (isLoggedIn) { + boolean showActivity = true; + boolean showExplore = false; + if (activity != null) { + showActivity = !NavigationHelperKt.isNavRootInCurrentTabs("notification_viewer_nav_graph"); + showExplore = !NavigationHelperKt.isNavRootInCurrentTabs("discover_nav_graph"); + } + if (showActivity) { + screen.addPreference(getPreference(R.string.action_notif, R.drawable.ic_not_liked, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToNotifications().setType("notif"); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + } + if (showExplore) { + screen.addPreference(getPreference(R.string.title_discover, R.drawable.ic_explore_24, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToDiscover(); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + } + + screen.addPreference(getPreference(R.string.action_ayml, R.drawable.ic_suggested_users, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToNotifications().setType("ayml"); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + screen.addPreference(getPreference(R.string.action_archive, R.drawable.ic_archive, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToStoryList("archive"); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + } + + // Check if favorites has been added as a tab. And if so, do not add in this list + boolean showFavorites = true; + if (activity != null) { + showFavorites = !NavigationHelperKt.isNavRootInCurrentTabs("favorites_nav_graph"); + } + if (showFavorites) { + screen.addPreference(getPreference(R.string.title_favorites, R.drawable.ic_star_24, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToFavorites(); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + } + + screen.addPreference(getDivider(context)); + screen.addPreference(getPreference(R.string.action_settings, R.drawable.ic_outline_settings_24, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToSettings(); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + screen.addPreference(getPreference(R.string.backup_and_restore, R.drawable.ic_settings_backup_restore_24, preference -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToBackup(); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + screen.addPreference(getPreference(R.string.action_about, R.drawable.ic_outline_info_24, preference1 -> { + if (isSafeToNavigate(navController)) { + try { + final NavDirections navDirections = MorePreferencesFragmentDirections.actionToAbout(); + navController.navigate(navDirections); + } catch (Exception e) { + Log.e(TAG, "setupPreferenceScreen: ", e); + } + } + return true; + })); + + screen.addPreference(getDivider(context)); + screen.addPreference(getPreference( + R.string.version, + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")", + -1, + preference -> { + if (BuildConfig.isPre) return true; + if (activity == null) return false; + FlavorTown.updateCheck(activity, true); + return true; + }) + ); + screen.addPreference(getDivider(context)); + + final Preference reminderPreference = getPreference(R.string.reminder, R.string.reminder_summary, R.drawable.ic_warning, null); + if (reminderPreference == null) return; + reminderPreference.setSelectable(false); + screen.addPreference(reminderPreference); + } + + private boolean isSafeToNavigate(final NavController navController) { + return navController.getCurrentDestination() != null + && navController.getCurrentDestination().getId() == R.id.morePreferencesFragment; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + if (resultCode == Constants.LOGIN_RESULT_CODE) { + if (data == null) return; + final String cookie = data.getStringExtra("cookie"); + CookieUtils.setupCookies(cookie); + settingsHelper.putString(Constants.COOKIE, cookie); + // No use as the timing of show is unreliable + // Toast.makeText(getContext(), R.string.login_success_loading_cookies, Toast.LENGTH_SHORT).show(); + + // adds cookies to database for quick access + final long uid = CookieUtils.getUserIdFromCookie(cookie); + final UserRepository userRepository = UserRepository.Companion.getInstance(); + userRepository + .getUserInfo(uid, CoroutineUtilsKt.getContinuation((user, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "Error fetching user info", throwable); + return; + } + if (user != null) { + accountRepository.insertOrUpdateAccount( + uid, + user.getUsername(), + cookie, + user.getFullName(), + user.getProfilePicUrl(), + CoroutineUtilsKt.getContinuation((account, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "onActivityResult: ", throwable1); + return; + } + AppExecutors.INSTANCE.getMainThread().execute(() -> { + final Context context = getContext(); + if (context == null) return; + ProcessPhoenix.triggerRebirth(context); + }, 200); + }), Dispatchers.getIO()) + ); + } + }), Dispatchers.getIO())); + } + } + + @Nullable + private AccountSwitcherPreference getAccountSwitcherPreference(final String cookie, final Context context) { + if (context == null) return null; + return new AccountSwitcherPreference(context, cookie, accountRepository, v -> showAccountSwitcherDialog()); + } + + private void showAccountSwitcherDialog() { + final AccountSwitcherDialogFragment dialogFragment = new AccountSwitcherDialogFragment(dialog -> { + dialog.dismiss(); + startActivityForResult(new Intent(getContext(), Login.class), Constants.LOGIN_RESULT_CODE); + }); + final FragmentManager fragmentManager = getChildFragmentManager(); + dialogFragment.show(fragmentManager, "accountSwitcher"); + } + + @Nullable + private Preference getPreference(final int title, + final int icon, + final Preference.OnPreferenceClickListener clickListener) { + return getPreference(title, -1, icon, clickListener); + } + + @Nullable + private Preference getPreference(final int title, + final int summary, + final int icon, + final Preference.OnPreferenceClickListener clickListener) { + String string = null; + if (summary > 0) { + try { + string = getString(summary); + } catch (Resources.NotFoundException e) { + Log.e(TAG, "Error", e); + } + } + return getPreference(title, string, icon, clickListener); + } + + @Nullable + private Preference getPreference(final int title, + final String summary, + final int icon, + final Preference.OnPreferenceClickListener clickListener) { + final Context context = getContext(); + if (context == null) return null; + final Preference preference = new Preference(context); + if (icon <= 0) preference.setIconSpaceReserved(false); + if (icon > 0) preference.setIcon(icon); + preference.setTitle(title); + if (!TextUtils.isEmpty(summary)) { + preference.setSummary(summary); + } + preference.setOnPreferenceClickListener(clickListener); + return preference; + } + + // public static class MoreHeaderPreference extends Preference { + // + // public MoreHeaderPreference(final Context context) { + // super(context); + // setLayoutResource(R.layout.pref_more_header); + // setSelectable(false); + // } + // } + + public static class AccountSwitcherPreference extends Preference { + + private final String cookie; + private final AccountRepository accountRepository; + private final View.OnClickListener onClickListener; + + public AccountSwitcherPreference(final Context context, + final String cookie, + final AccountRepository accountRepository, + final View.OnClickListener onClickListener) { + super(context); + this.cookie = cookie; + this.accountRepository = accountRepository; + this.onClickListener = onClickListener; + setLayoutResource(R.layout.pref_account_switcher); + } + + @SuppressLint("SetTextI18n") + @Override + public void onBindViewHolder(final PreferenceViewHolder holder) { + final View root = holder.itemView; + if (onClickListener != null) root.setOnClickListener(onClickListener); + final PrefAccountSwitcherBinding binding = PrefAccountSwitcherBinding.bind(root); + final long uid = CookieUtils.getUserIdFromCookie(cookie); + if (uid <= 0) return; + accountRepository.getAccount( + uid, + CoroutineUtilsKt.getContinuation((account, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "onBindViewHolder: ", throwable); + return; + } + if (account == null) return; + binding.getRoot().post(() -> { + binding.fullName.setText(account.getFullName()); + binding.username.setText("@" + account.getUsername()); + binding.profilePic.setImageURI(account.getProfilePic()); + binding.getRoot().requestLayout(); + }); + }), Dispatchers.getIO()) + ); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/NotificationsPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/NotificationsPreferencesFragment.java new file mode 100644 index 0000000..2a2dd49 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/NotificationsPreferencesFragment.java @@ -0,0 +1,42 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import awais.instagrabber.R; + +public class NotificationsPreferencesFragment extends BasePreferencesFragment { + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + screen.addPreference(getActivityNotificationsPreference(context)); + // screen.addPreference(getDMNotificationsPreference(context)); + } + + private Preference getActivityNotificationsPreference(@NonNull final Context context) { + return PreferenceHelper.getSwitchPreference( + context, + PreferenceKeys.CHECK_ACTIVITY, + R.string.activity_setting, + -1, + false, + (preference, newValue) -> { + shouldRecreate(); + return true; + }); + } + + private Preference getDMNotificationsPreference(@NonNull final Context context) { + return PreferenceHelper.getSwitchPreference( + context, + PreferenceKeys.PREF_ENABLE_DM_NOTIFICATIONS, + R.string.enable_dm_notifications, + -1, + false, + null); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/PostPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/PostPreferencesFragment.java new file mode 100644 index 0000000..4c91407 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/PostPreferencesFragment.java @@ -0,0 +1,69 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreferenceCompat; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.KeywordsFilterDialog; + +public class PostPreferencesFragment extends BasePreferencesFragment { + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + // generalCategory.addPreference(getAutoPlayVideosPreference(context)); + screen.addPreference(getBackgroundPlayPreference(context)); + screen.addPreference(getAlwaysMuteVideosPreference(context)); + screen.addPreference(getToggleKeywordFilterPreference(context)); + screen.addPreference(getEditKeywordFilterPreference(context)); + } + +// private Preference getAutoPlayVideosPreference(@NonNull final Context context) { +// final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); +// preference.setKey(Constants.AUTOPLAY_VIDEOS); +// preference.setTitle(R.string.post_viewer_autoplay_video); +// preference.setIconSpaceReserved(false); +// return preference; +// } + + private Preference getBackgroundPlayPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.PLAY_IN_BACKGROUND); + preference.setTitle(R.string.post_viewer_background_play); + preference.setSummary(R.string.post_viewer_background_play_summary); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getAlwaysMuteVideosPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.MUTED_VIDEOS); + preference.setTitle(R.string.post_viewer_muted_autoplay); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getToggleKeywordFilterPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.TOGGLE_KEYWORD_FILTER); + preference.setDefaultValue(false); + preference.setTitle(R.string.toggle_keyword_filter); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getEditKeywordFilterPreference(@NonNull final Context context){ + final Preference preference = new Preference(context); + preference.setTitle(R.string.edit_keyword_filter); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(view ->{ + new KeywordsFilterDialog().show(getParentFragmentManager(), null); + return true; + }); + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/PreferenceHelper.java b/app/src/main/java/awais/instagrabber/fragments/settings/PreferenceHelper.java new file mode 100644 index 0000000..824c18d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/PreferenceHelper.java @@ -0,0 +1,30 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.preference.Preference.OnPreferenceChangeListener; +import androidx.preference.SwitchPreferenceCompat; + +public final class PreferenceHelper { + + public static SwitchPreferenceCompat getSwitchPreference(@NonNull final Context context, + @NonNull final String key, + @StringRes final int titleResId, + @StringRes final int summaryResId, + final boolean iconSpaceReserved, + final OnPreferenceChangeListener onPreferenceChangeListener) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(key); + preference.setTitle(titleResId); + preference.setIconSpaceReserved(iconSpaceReserved); + if (summaryResId != -1) { + preference.setSummary(summaryResId); + } + if (onPreferenceChangeListener != null) { + preference.setOnPreferenceChangeListener(onPreferenceChangeListener); + } + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/PreferenceKeys.kt b/app/src/main/java/awais/instagrabber/fragments/settings/PreferenceKeys.kt new file mode 100644 index 0000000..450b31f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/PreferenceKeys.kt @@ -0,0 +1,46 @@ +package awais.instagrabber.fragments.settings + +object PreferenceKeys { + // new boolean prefs + const val PREF_ENABLE_DM_NOTIFICATIONS = "enable_dm_notifications" + const val PREF_ENABLE_DM_AUTO_REFRESH = "enable_dm_auto_refresh" + const val PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT = "enable_dm_auto_refresh_freq_unit" + const val PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER = "enable_dm_auto_refresh_freq_number" + const val PREF_ENABLE_SENTRY = "enable_sentry" + const val PREF_TAB_ORDER = "tab_order" + const val PREF_SHOWN_COUNT_TOOLTIP = "shown_count_tooltip" + const val PREF_SEARCH_FOCUS_KEYBOARD = "search_focus_keyboard" + const val PREF_AUTO_BACKUP_ENABLED = "auto_backup_enabled" + const val PREF_DISABLE_SCREEN_TRANSITIONS = "disable_screen_transitions" + const val PREF_STORY_SHOW_LIST = "story_show_list" + + // string prefs + const val FOLDER_PATH = "custom_path" + const val DATE_TIME_FORMAT = "date_time_format" + const val DATE_TIME_SELECTION = "date_time_selection" + const val CUSTOM_DATE_TIME_FORMAT = "date_time_custom_format" + const val APP_THEME = "app_theme_v19" + const val APP_LANGUAGE = "app_language_v19" + const val STORY_SORT = "story_sort" + const val PREF_BARINSTA_DIR_URI = "barinsta_dir_uri" + + // set string prefs + const val KEYWORD_FILTERS = "keyword_filters" + + // old boolean prefs + const val DOWNLOAD_USER_FOLDER = "download_user_folder" + const val TOGGLE_KEYWORD_FILTER = "toggle_keyword_filter" + const val DOWNLOAD_PREPEND_USER_NAME = "download_user_name" + const val PLAY_IN_BACKGROUND = "play_in_background" + const val AUTOPLAY_VIDEOS_STORIES = "autoplay_videos" + const val MUTED_VIDEOS = "muted_videos" +// const val SHOW_CAPTIONS = "show_captions" + const val CUSTOM_DATE_TIME_FORMAT_ENABLED = "data_time_custom_enabled" + const val SWAP_DATE_TIME_FORMAT_ENABLED = "swap_date_time_enabled" + const val MARK_AS_SEEN = "mark_as_seen" + const val HIDE_MUTED_REELS = "hide_muted_reels" + const val DM_MARK_AS_SEEN = "dm_mark_as_seen" + const val CHECK_ACTIVITY = "check_activity" + const val CHECK_UPDATES = "check_updates" + const val FLAG_SECURE = "flag_secure" +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/SettingCategory.java b/app/src/main/java/awais/instagrabber/fragments/settings/SettingCategory.java new file mode 100644 index 0000000..88ba5e6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/SettingCategory.java @@ -0,0 +1,6 @@ +package awais.instagrabber.fragments.settings; + +public enum SettingCategory { + GENERAL, + // add more as and when required +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/SettingsPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/SettingsPreferencesFragment.java new file mode 100644 index 0000000..b45739a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/SettingsPreferencesFragment.java @@ -0,0 +1,101 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.navigation.NavDirections; +import androidx.navigation.fragment.NavHostFragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.TextUtils; + +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToDm; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToDownloads; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToGeneral; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToLocale; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToNotifications; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToPost; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToStories; +import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToTheme; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SettingsPreferencesFragment extends BasePreferencesFragment { + private static final String TAG = SettingsPreferencesFragment.class.getSimpleName(); + private static final List screens = ImmutableList.of( + new SettingScreen(R.string.pref_category_general, actionSettingsToGeneral()), + new SettingScreen(R.string.pref_category_theme, actionSettingsToTheme()), + new SettingScreen(R.string.pref_category_locale, actionSettingsToLocale()), + new SettingScreen(R.string.pref_category_post, actionSettingsToPost()), + new SettingScreen(R.string.pref_category_stories, actionSettingsToStories(), true), + new SettingScreen(R.string.pref_category_dm, actionSettingsToDm(), true), + new SettingScreen(R.string.pref_category_notifications, actionSettingsToNotifications(), true), + new SettingScreen(R.string.pref_category_downloads, actionSettingsToDownloads()) + ); + + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + final String cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + for (final SettingScreen settingScreen : screens) { + if (settingScreen.isLoginRequired() && !isLoggedIn) continue; + screen.addPreference(getNavPreference(context, settingScreen)); + } + // else { + // final PreferenceCategory anonUsersPreferenceCategory = new PreferenceCategory(context); + // screen.addPreference(anonUsersPreferenceCategory); + // anonUsersPreferenceCategory.setIconSpaceReserved(false); + // anonUsersPreferenceCategory.setTitle(R.string.anonymous_settings); + // } + } + + private Preference getNavPreference(@NonNull final Context context, + @NonNull final SettingScreen settingScreen) { + final Preference preference = new Preference(context); + preference.setTitle(settingScreen.getTitleResId()); + preference.setIconSpaceReserved(false); + preference.setOnPreferenceClickListener(preference1 -> { + NavHostFragment.findNavController(this).navigate(settingScreen.getDirections()); + return true; + }); + return preference; + } + + private static class SettingScreen { + private final int titleResId; + private final NavDirections directions; + private final boolean loginRequired; + + public SettingScreen(@StringRes final int titleResId, final NavDirections directions) { + this(titleResId, directions, false); + } + + public SettingScreen(@StringRes final int titleResId, final NavDirections directions, final boolean loginRequired) { + this.titleResId = titleResId; + this.directions = directions; + this.loginRequired = loginRequired; + } + + public int getTitleResId() { + return titleResId; + } + + public NavDirections getDirections() { + return directions; + } + + public boolean isLoginRequired() { + return loginRequired; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/StoriesPreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/StoriesPreferencesFragment.java new file mode 100644 index 0000000..62b7c0a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/StoriesPreferencesFragment.java @@ -0,0 +1,75 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreferenceCompat; + +import awais.instagrabber.R; + +public class StoriesPreferencesFragment extends BasePreferencesFragment { + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + screen.addPreference(getStorySortPreference(context)); + screen.addPreference(getHideMutedReelsPreference(context)); + screen.addPreference(getMarkStoriesSeenPreference(context)); + screen.addPreference(getAutoPlayPreference(context)); + screen.addPreference(getStoryListPreference(context)); + } + + private Preference getStorySortPreference(@NonNull final Context context) { + final ListPreference preference = new ListPreference(context); + preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + final int length = getResources().getStringArray(R.array.story_sorts).length; + final String[] values = new String[length]; + for (int i = 0; i < length; i++) { + values[i] = String.valueOf(i); + } + preference.setKey(PreferenceKeys.STORY_SORT); + preference.setTitle(R.string.story_sort_setting); + preference.setDialogTitle(R.string.story_sort_setting); + preference.setEntries(R.array.story_sorts); + preference.setIconSpaceReserved(false); + preference.setEntryValues(values); + return preference; + } + + private Preference getHideMutedReelsPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.HIDE_MUTED_REELS); + preference.setTitle(R.string.hide_muted_reels_setting); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getMarkStoriesSeenPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.MARK_AS_SEEN); + preference.setTitle(R.string.mark_as_seen_setting); + preference.setSummary(R.string.mark_as_seen_setting_summary); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getAutoPlayPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES); + preference.setTitle(R.string.autoplay_stories_setting); + preference.setIconSpaceReserved(false); + return preference; + } + + private Preference getStoryListPreference(@NonNull final Context context) { + final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); + preference.setKey(PreferenceKeys.PREF_STORY_SHOW_LIST); + preference.setTitle(R.string.story_list_setting); + preference.setSummary(R.string.story_list_setting_summary); + preference.setIconSpaceReserved(false); + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/ThemePreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/ThemePreferencesFragment.java new file mode 100644 index 0000000..21435b0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/settings/ThemePreferencesFragment.java @@ -0,0 +1,94 @@ +package awais.instagrabber.fragments.settings; + +import android.content.Context; +import android.content.res.TypedArray; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import awais.instagrabber.R; +import awais.instagrabber.utils.Constants; + +public class ThemePreferencesFragment extends BasePreferencesFragment { + @Override + void setupPreferenceScreen(final PreferenceScreen screen) { + final Context context = getContext(); + if (context == null) return; + screen.addPreference(getThemePreference(context)); + screen.addPreference(getLightThemePreference(context)); + screen.addPreference(getDarkThemePreference(context)); + } + + private Preference getThemePreference(@NonNull final Context context) { + final ListPreference preference = new ListPreference(context); + preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + final int length = getResources().getStringArray(R.array.theme_presets).length; + final String[] values = new String[length]; + for (int i = 0; i < length; i++) { + values[i] = String.valueOf(i); + } + preference.setKey(PreferenceKeys.APP_THEME); + preference.setTitle(R.string.theme_settings); + preference.setDialogTitle(R.string.theme_settings); + preference.setEntries(R.array.theme_presets); + preference.setIconSpaceReserved(false); + preference.setEntryValues(values); + preference.setOnPreferenceChangeListener((preference1, newValue) -> { + shouldRecreate(); + return true; + }); + return preference; + } + + private Preference getLightThemePreference(final Context context) { + final ListPreference preference = new ListPreference(context); + preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + final TypedArray lightThemeValues = getResources().obtainTypedArray(R.array.light_theme_values); + final int length = lightThemeValues.length(); + final String[] values = new String[length]; + for (int i = 0; i < length; i++) { + final int resourceId = lightThemeValues.getResourceId(i, -1); + if (resourceId < 0) continue; + values[i] = getResources().getResourceEntryName(resourceId); + } + lightThemeValues.recycle(); + preference.setKey(Constants.PREF_LIGHT_THEME); + preference.setTitle(R.string.light_theme_settings); + preference.setDialogTitle(R.string.light_theme_settings); + preference.setEntries(R.array.light_themes); + preference.setIconSpaceReserved(false); + preference.setEntryValues(values); + preference.setOnPreferenceChangeListener((preference1, newValue) -> { + shouldRecreate(); + return true; + }); + return preference; + } + + private Preference getDarkThemePreference(final Context context) { + final ListPreference preference = new ListPreference(context); + preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + final TypedArray darkThemeValues = getResources().obtainTypedArray(R.array.dark_theme_values); + final int length = darkThemeValues.length(); + final String[] values = new String[length]; + for (int i = 0; i < length; i++) { + final int resourceId = darkThemeValues.getResourceId(i, -1); + if (resourceId < 0) continue; + values[i] = getResources().getResourceEntryName(resourceId); + } + darkThemeValues.recycle(); + preference.setKey(Constants.PREF_DARK_THEME); + preference.setTitle(R.string.dark_theme_settings); + preference.setDialogTitle(R.string.dark_theme_settings); + preference.setEntries(R.array.dark_themes); + preference.setIconSpaceReserved(false); + preference.setEntryValues(values); + preference.setOnPreferenceChangeListener((preference1, newValue) -> { + shouldRecreate(); + return true; + }); + return preference; + } +} diff --git a/app/src/main/java/awais/instagrabber/interfaces/FetchListener.java b/app/src/main/java/awais/instagrabber/interfaces/FetchListener.java new file mode 100755 index 0000000..e424f53 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/interfaces/FetchListener.java @@ -0,0 +1,9 @@ +package awais.instagrabber.interfaces; + +public interface FetchListener { + void onResult(T result); + + default void doBefore() {} + + default void onFailure(Throwable t) {} +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/interfaces/LazyLoadListener.java b/app/src/main/java/awais/instagrabber/interfaces/LazyLoadListener.java new file mode 100755 index 0000000..ca98a3f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/interfaces/LazyLoadListener.java @@ -0,0 +1,5 @@ +package awais.instagrabber.interfaces; + +public interface LazyLoadListener { + void onLoadMore(final int page, final int totalItemsCount); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/interfaces/OnGroupClickListener.java b/app/src/main/java/awais/instagrabber/interfaces/OnGroupClickListener.java new file mode 100755 index 0000000..d776385 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/interfaces/OnGroupClickListener.java @@ -0,0 +1,5 @@ +package awais.instagrabber.interfaces; + +public interface OnGroupClickListener { + void toggleGroup(final int flatPos); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/interfaces/SwipeEvent.java b/app/src/main/java/awais/instagrabber/interfaces/SwipeEvent.java new file mode 100755 index 0000000..89eba32 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/interfaces/SwipeEvent.java @@ -0,0 +1,5 @@ +package awais.instagrabber.interfaces; + +public interface SwipeEvent { + void onSwipe(final boolean isRight); +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt b/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt new file mode 100644 index 0000000..69d2afa --- /dev/null +++ b/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt @@ -0,0 +1,188 @@ +package awais.instagrabber.managers + +import android.content.ContentResolver +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Resource.Companion.error +import awais.instagrabber.models.Resource.Companion.loading +import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.models.enums.BroadcastItemType +import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.DirectThread +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.getCsrfTokenFromCookie +import awais.instagrabber.utils.getUserIdFromCookie +import awais.instagrabber.webservices.DirectMessagesRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* + +object DirectMessagesManager { + val inboxManager: InboxManager by lazy { InboxManager(false) } + val pendingInboxManager: InboxManager by lazy { InboxManager(true) } + + private val TAG = DirectMessagesManager::class.java.simpleName + private val viewerId: Long + private val deviceUuid: String + private val csrfToken: String + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } + + fun moveThreadFromPending(threadId: String) { + val pendingThreads = pendingInboxManager.threads.value ?: return + val index = pendingThreads.indexOfFirst { it.threadId == threadId } + if (index < 0) return + val thread = pendingThreads[index] + val threadFirstDirectItem = thread.firstDirectItem ?: return + val threads = inboxManager.threads.value + var insertIndex = 0 + if (threads != null) { + for (tempThread in threads) { + val firstDirectItem = tempThread.firstDirectItem ?: continue + val timestamp = firstDirectItem.getTimestamp() + if (timestamp < threadFirstDirectItem.getTimestamp()) { + break + } + insertIndex++ + } + } + thread.pending = false + inboxManager.addThread(thread, insertIndex) + pendingInboxManager.removeThread(threadId) + val currentTotal = inboxManager.getPendingRequestsTotal().value ?: return + inboxManager.setPendingRequestsTotal(currentTotal - 1) + } + + fun getThreadManager( + threadId: String, + pending: Boolean, + currentUser: User, + contentResolver: ContentResolver, + ): ThreadManager { + return ThreadManager(threadId, pending, currentUser, contentResolver, viewerId, csrfToken, deviceUuid) + } + + suspend fun createThread(userPk: Long): DirectThread = + directMessagesRepository.createThread(csrfToken, viewerId, deviceUuid, listOf(userPk), null) + + fun sendMedia(recipient: RankedRecipient, mediaId: String, secondId: String?, itemType: BroadcastItemType, scope: CoroutineScope) { + sendMedia(setOf(recipient), mediaId, secondId, itemType, scope) + } + + fun sendMedia( + recipients: Set, + mediaId: String, + secondId: String?, + itemType: BroadcastItemType, + scope: CoroutineScope, + ) { + val threadIds = recipients.mapNotNull { it.thread?.threadId } + val userIdsTemp = recipients.mapNotNull { it.user?.pk } + val userIds = userIdsTemp.map { listOf(it.toString(10)) } + sendMedia(threadIds, userIds, mediaId, secondId, itemType, scope) { + inboxManager.refresh(scope) + } + } + + private fun sendMedia( + threadIds: List, + userIds: List>, + mediaId: String, + secondId: String?, + itemType: BroadcastItemType, + scope: CoroutineScope, + callback: (() -> Unit)?, + ): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + when (itemType) { + BroadcastItemType.MEDIA_SHARE -> directMessagesRepository.broadcastMediaShare( + csrfToken, + viewerId, + deviceUuid, + UUID.randomUUID().toString(), + ThreadIdsOrUserIds(threadIds, userIds), + mediaId, + secondId + ) + BroadcastItemType.PROFILE -> directMessagesRepository.broadcastProfile( + csrfToken, + viewerId, + deviceUuid, + UUID.randomUUID().toString(), + ThreadIdsOrUserIds(threadIds, userIds), + mediaId + ) + BroadcastItemType.STORY -> directMessagesRepository.broadcastStory( + csrfToken, + viewerId, + deviceUuid, + UUID.randomUUID().toString(), + ThreadIdsOrUserIds(threadIds, userIds), + mediaId, + secondId!! + ) + } + data.postValue(success(Any())) + callback?.invoke() + } catch (e: Exception) { + Log.e(TAG, "sendMedia: ", e) + data.postValue(error(e.message, null)) + callback?.invoke() + } + } + return data + } + + fun replyToStory( + recipientId: Long?, + reelId: String?, + mediaId: String?, + text: String, + scope: CoroutineScope + ): LiveData> { + Log.d("austin_debug", "replying") + val data = MutableLiveData>() + data.postValue(loading(null)) + if (recipientId == null || reelId == null || mediaId == null) { + data.postValue(error("arguments are null", null)) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.broadcastStoryReply( + csrfToken, + viewerId, + deviceUuid, + ThreadIdsOrUserIds.Companion.ofOneUser(recipientId.toString(10)), + text, + mediaId, + reelId + ) + inboxManager.refresh(scope) + data.postValue(success(null)) + } + catch (e: Exception) { + Log.e(TAG, "story reply: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + init { + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + viewerId = getUserIdFromCookie(cookie) + deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + val csrfToken = getCsrfTokenFromCookie(cookie) + require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } + this.csrfToken = csrfToken + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/managers/InboxManager.kt b/app/src/main/java/awais/instagrabber/managers/InboxManager.kt new file mode 100644 index 0000000..f7dd968 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/managers/InboxManager.kt @@ -0,0 +1,322 @@ +package awais.instagrabber.managers + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import awais.instagrabber.R +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Resource.Companion.error +import awais.instagrabber.models.Resource.Companion.loading +import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.* +import awais.instagrabber.utils.* +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.webservices.DirectMessagesRepository +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.google.common.collect.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import retrofit2.Call +import java.util.* +import java.util.concurrent.TimeUnit + +class InboxManager(private val pending: Boolean) { + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } + private val inbox = MutableLiveData>(success(null)) + private val unseenCount = MutableLiveData>() + private val pendingRequestsTotal = MutableLiveData(0) + val threads: LiveData> + private var inboxRequest: Call? = null + private var unseenCountRequest: Call? = null + private var seqId: Long = 0 + private var cursor: String? = null + private var hasOlder = true + var viewer: User? = null + private set + + fun getInbox(): LiveData> { + return Transformations.distinctUntilChanged(inbox) + } + + fun getUnseenCount(): LiveData> { + return Transformations.distinctUntilChanged(unseenCount) + } + + fun getPendingRequestsTotal(): LiveData { + return Transformations.distinctUntilChanged(pendingRequestsTotal) + } + + fun fetchInbox(scope: CoroutineScope) { + val inboxResource = inbox.value + if (inboxResource != null && inboxResource.status === Resource.Status.LOADING || !hasOlder) return + inbox.postValue(loading(currentDirectInbox)) + scope.launch(Dispatchers.IO) { + try { + val inboxValue = if (pending) { + directMessagesRepository.fetchPendingInbox(cursor, seqId) + } else { + directMessagesRepository.fetchInbox(cursor, seqId) + } + parseInboxResponse(inboxValue) + } catch (e: Exception) { + inbox.postValue(error(e.message, currentDirectInbox)) + hasOlder = false + } + } + } + + fun fetchUnseenCount(scope: CoroutineScope) { + val unseenCountResource = unseenCount.value + if (unseenCountResource != null && unseenCountResource.status === Resource.Status.LOADING) return + stopCurrentUnseenCountRequest() + unseenCount.postValue(loading(currentUnseenCount)) + scope.launch(Dispatchers.IO) { + try { + val directBadgeCount = directMessagesRepository.fetchUnseenCount() + unseenCount.postValue(success(directBadgeCount.badgeCount)) + } catch (e: Exception) { + Log.e(TAG, "Failed fetching unseen count", e) + unseenCount.postValue(error(e.message, currentUnseenCount)) + } + } + } + + fun refresh(scope: CoroutineScope) { + cursor = null + seqId = 0 + hasOlder = true + fetchInbox(scope) + if (!pending) { + fetchUnseenCount(scope) + } + } + + private val currentDirectInbox: DirectInbox? + get() { + val inboxResource = inbox.value + return inboxResource?.data + } + + private fun parseInboxResponse(response: DirectInboxResponse) { + if (response.status != "ok") { + Log.e(TAG, "DM inbox fetch response: status not ok") + inbox.postValue(error(R.string.generic_not_ok_response, currentDirectInbox)) + hasOlder = false + return + } + seqId = response.seqId + if (viewer == null) { + viewer = response.viewer + } + val inbox = response.inbox ?: return + if (!cursor.isNullOrBlank()) { + val currentDirectInbox = currentDirectInbox + currentDirectInbox?.let { + val threads = it.threads + val threadsCopy = if (threads == null) LinkedList() else LinkedList(threads) + threadsCopy.addAll(inbox.threads ?: emptyList()) + inbox.threads = threadsCopy + } + } + this.inbox.postValue(success(inbox)) + cursor = inbox.oldestCursor + hasOlder = inbox.hasOlder + pendingRequestsTotal.postValue(response.pendingRequestsTotal) + } + + fun setThread( + threadId: String, + thread: DirectThread, + ) { + val inbox = currentDirectInbox ?: return + val index = getThreadIndex(threadId, inbox) + setThread(inbox, index, thread) + } + + private fun setThread( + inbox: DirectInbox, + index: Int, + thread: DirectThread, + ) { + if (index < 0) return + synchronized(this.inbox) { + val threads = inbox.threads + val threadsCopy = if (threads == null) LinkedList() else LinkedList(threads) + threadsCopy[index] = thread + try { + val clone = inbox.clone() as DirectInbox + clone.threads = threadsCopy + this.inbox.postValue(success(clone)) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "setThread: ", e) + } + } + } + + fun addItemsToThread( + threadId: String, + insertIndex: Int, + items: Collection, + ) { + val inbox = currentDirectInbox ?: return + synchronized(THREAD_LOCKS.getUnchecked(threadId)) { + val index = getThreadIndex(threadId, inbox) + if (index < 0) return + val threads = inbox.threads ?: return + val thread = threads[index] + val threadItems = thread.items + val list = if (threadItems == null) LinkedList() else LinkedList(threadItems) + if (insertIndex >= 0) { + list.addAll(insertIndex, items) + } else { + list.addAll(items) + } + try { + val threadClone = thread.clone() as DirectThread + threadClone.items = list + setThread(inbox, index, threadClone) + } catch (e: Exception) { + Log.e(TAG, "addItemsToThread: ", e) + } + } + } + + fun setItemsToThread( + threadId: String, + updatedItems: List, + ) { + val inbox = currentDirectInbox ?: return + synchronized(THREAD_LOCKS.getUnchecked(threadId)) { + val index = getThreadIndex(threadId, inbox) + if (index < 0) return + val threads = inbox.threads ?: return + val thread = threads[index] + try { + val threadClone = thread.clone() as DirectThread + threadClone.items = updatedItems + setThread(inbox, index, threadClone) + } catch (e: Exception) { + Log.e(TAG, "setItemsToThread: ", e) + } + } + } + + private fun getThreadIndex( + threadId: String, + inbox: DirectInbox, + ): Int { + val threads = inbox.threads + return if (threads == null || threads.isEmpty()) { + -1 + } else threads.indexOfFirst { it.threadId == threadId } + } + + private val currentUnseenCount: Int? + get() { + val unseenCountResource = unseenCount.value + return unseenCountResource?.data + } + + private fun stopCurrentInboxRequest() { + inboxRequest?.let { + if (it.isCanceled || it.isExecuted) return + it.cancel() + } + inboxRequest = null + } + + private fun stopCurrentUnseenCountRequest() { + unseenCountRequest?.let { + if (it.isCanceled || it.isExecuted) return + it.cancel() + } + unseenCountRequest = null + } + + fun onDestroy() { + stopCurrentInboxRequest() + stopCurrentUnseenCountRequest() + } + + fun addThread(thread: DirectThread, insertIndex: Int) { + if (insertIndex < 0) return + synchronized(inbox) { + val currentDirectInbox = currentDirectInbox ?: return + val threads = currentDirectInbox.threads + val threadsCopy = if (threads == null) LinkedList() else LinkedList(threads) + threadsCopy.add(insertIndex, thread) + try { + val clone = currentDirectInbox.clone() as DirectInbox + clone.threads = threadsCopy + inbox.postValue(success(clone)) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "setThread: ", e) + } + } + } + + fun removeThread(threadId: String) { + synchronized(inbox) { + val currentDirectInbox = currentDirectInbox ?: return + val threads = currentDirectInbox.threads ?: return + val threadsCopy = threads.asSequence().filter { it.threadId != threadId }.toList() + try { + val clone = currentDirectInbox.clone() as DirectInbox + clone.threads = threadsCopy + inbox.postValue(success(clone)) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "setThread: ", e) + } + } + } + + fun setPendingRequestsTotal(total: Int) { + pendingRequestsTotal.postValue(total) + } + + fun containsThread(threadId: String?): Boolean { + if (threadId == null) return false + synchronized(inbox) { + val currentDirectInbox = currentDirectInbox ?: return false + val threads = currentDirectInbox.threads ?: return false + return threads.any { it.threadId == threadId } + } + } + + companion object { + private val THREAD_LOCKS = CacheBuilder + .newBuilder() + .expireAfterAccess(1, TimeUnit.MINUTES) // max lock time ever expected + .build(CacheLoader.from { Object() }) + private val THREAD_COMPARATOR = Comparator { t1: DirectThread, t2: DirectThread -> + val t1FirstDirectItem = t1.firstDirectItem + val t2FirstDirectItem = t2.firstDirectItem + if (t1FirstDirectItem == null && t2FirstDirectItem == null) return@Comparator 0 + if (t1FirstDirectItem == null) return@Comparator 1 + if (t2FirstDirectItem == null) return@Comparator -1 + t2FirstDirectItem.getTimestamp().compareTo(t1FirstDirectItem.getTimestamp()) + } + } + + init { + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + val viewerId = getUserIdFromCookie(cookie) + val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + val csrfToken = getCsrfTokenFromCookie(cookie) + require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } + + // Transformations + threads = Transformations.distinctUntilChanged(Transformations.map(inbox) { inboxResource: Resource -> + // if (inboxResource == null) { + // return@map emptyList() + // } + val inbox = inboxResource.data + val threads = inbox?.threads ?: emptyList() + ImmutableList.sortedCopyOf(THREAD_COMPARATOR, threads) + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt b/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt new file mode 100644 index 0000000..b99fe3b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt @@ -0,0 +1,1391 @@ +package awais.instagrabber.managers + +import android.content.ContentResolver +import android.net.Uri +import android.util.Log +import androidx.core.util.Pair +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations.distinctUntilChanged +import androidx.lifecycle.Transformations.map +import awais.instagrabber.R +import awais.instagrabber.customviews.emoji.Emoji +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Resource.Companion.error +import awais.instagrabber.models.Resource.Companion.loading +import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.models.enums.DirectItemType +import awais.instagrabber.repositories.requests.UploadFinishOptions +import awais.instagrabber.repositories.requests.VideoOptions +import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds +import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds.Companion.of +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.* +import awais.instagrabber.repositories.responses.giphy.GiphyGif +import awais.instagrabber.utils.* +import awais.instagrabber.utils.MediaUploader.MediaUploadResponse +import awais.instagrabber.utils.MediaUploader.uploadPhoto +import awais.instagrabber.utils.MediaUploader.uploadVideo +import awais.instagrabber.utils.MediaUtils.OnInfoLoadListener +import awais.instagrabber.utils.MediaUtils.VideoInfo +import awais.instagrabber.utils.TextUtils.isEmpty +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.webservices.DirectMessagesRepository +import awais.instagrabber.webservices.FriendshipRepository +import awais.instagrabber.webservices.MediaRepository +import com.google.common.collect.ImmutableList +import com.google.common.collect.Iterables +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import retrofit2.Call +import java.io.IOException +import java.net.HttpURLConnection +import java.util.* +import java.util.stream.Collectors + +class ThreadManager( + private val threadId: String, + pending: Boolean, + private val currentUser: User?, + private val contentResolver: ContentResolver, + private val viewerId: Long, + private val csrfToken: String, + private val deviceUuid: String, +) { + private val _fetching = MutableLiveData>() + val fetching: LiveData> = _fetching + private val _replyToItem = MutableLiveData() + val replyToItem: LiveData = _replyToItem + private val _pendingRequests = MutableLiveData(null) + val pendingRequests: LiveData = _pendingRequests + private val inboxManager: InboxManager = if (pending) DirectMessagesManager.pendingInboxManager else DirectMessagesManager.inboxManager + private val threadIdsOrUserIds: ThreadIdsOrUserIds = of(threadId) + private val friendshipRepository: FriendshipRepository by lazy { FriendshipRepository.getInstance() } + private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } + + val thread: LiveData by lazy { + distinctUntilChanged(map(inboxManager.getInbox()) { inboxResource: Resource? -> + if (inboxResource == null) return@map null + val (threads) = inboxResource.data ?: return@map null + if (threads.isNullOrEmpty()) return@map null + val thread = threads.firstOrNull { it.threadId == threadId } + thread?.also { + cursor = thread.oldestCursor + hasOlder = thread.hasOlder + } + }) + } + val inputMode: LiveData by lazy { distinctUntilChanged(map(thread) { it?.inputMode ?: 1 }) } + val threadTitle: LiveData by lazy { distinctUntilChanged(map(thread) { it?.threadTitle }) } + val users: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.users ?: emptyList() }) } + val usersWithCurrent: LiveData> by lazy { + distinctUntilChanged(map(thread) { + if (it == null) return@map emptyList() + getUsersWithCurrentUser(it) + }) + } + val leftUsers: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.leftUsers ?: emptyList() }) } + val usersAndLeftUsers: LiveData, List>> by lazy { + distinctUntilChanged(map(thread) { + if (it == null) return@map Pair, List>(emptyList(), emptyList()) + val users = getUsersWithCurrentUser(it) + val leftUsers = it.leftUsers + Pair(users, leftUsers) + }) + } + val isPending: LiveData by lazy { distinctUntilChanged(map(thread) { it?.pending ?: true }) } + val adminUserIds: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.adminUserIds ?: emptyList() }) } + val items: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.items ?: emptyList() }) } + val isViewerAdmin: LiveData by lazy { distinctUntilChanged(map(thread) { it?.adminUserIds?.contains(viewerId) ?: false }) } + val isGroup: LiveData by lazy { distinctUntilChanged(map(thread) { it?.isGroup ?: false }) } + val isMuted: LiveData by lazy { distinctUntilChanged(map(thread) { it?.muted ?: false }) } + val isApprovalRequiredToJoin: LiveData by lazy { distinctUntilChanged(map(thread) { it?.approvalRequiredForNewMembers ?: false }) } + val isMentionsMuted: LiveData by lazy { distinctUntilChanged(map(thread) { it?.mentionsMuted ?: false }) } + val pendingRequestsCount: LiveData by lazy { distinctUntilChanged(map(_pendingRequests) { it?.totalParticipantRequests ?: 0 }) } + val inviter: LiveData by lazy { distinctUntilChanged(map(thread) { it?.inviter }) } + + private var hasOlder = true + private var cursor: String? = null + private var chatsRequest: Call? = null + + private fun getUsersWithCurrentUser(t: DirectThread): List { + val builder = ImmutableList.builder() + if (currentUser != null) { + builder.add(currentUser) + } + val users: List? = t.users + if (users != null) { + builder.addAll(users) + } + return builder.build() + } + + fun fetchChats(scope: CoroutineScope) { + val fetchingValue = _fetching.value + if (fetchingValue != null && fetchingValue.status === Resource.Status.LOADING || !hasOlder) return + _fetching.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + val threadFeedResponse = directMessagesRepository.fetchThread(threadId, cursor) + if (threadFeedResponse.status != null && threadFeedResponse.status != "ok") { + _fetching.postValue(error(R.string.generic_not_ok_response, null)) + return@launch + } + val thread = threadFeedResponse.thread + if (thread == null) { + _fetching.postValue(error("thread is null!", null)) + return@launch + } + setThread(thread) + _fetching.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "Failed fetching dm chats", e) + _fetching.postValue(error(e.message, null)) + hasOlder = false + } + } + if (cursor == null) { + fetchPendingRequests(scope) + } + } + + fun fetchPendingRequests(scope: CoroutineScope) { + val isGroup = isGroup.value + if (isGroup == null || !isGroup) return + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.participantRequests(threadId, 1) + _pendingRequests.postValue(response) + } catch (e: Exception) { + Log.e(TAG, "fetchPendingRequests: ", e) + } + } + } + + private fun setThread(thread: DirectThread, skipItems: Boolean) { + // if (thread.getInputMode() != 1 && thread.isGroup() && viewerIsAdmin) { + // fetchPendingRequests(); + // } + val items = thread.items + if (skipItems) { + val currentThread = this.thread.value + if (currentThread != null) { + thread.items = currentThread.items + } + } + if (!skipItems && !cursor.isNullOrBlank()) { + val currentThread = this.thread.value + if (currentThread != null) { + val currentItems = currentThread.items + val list = if (currentItems == null) LinkedList() else LinkedList(currentItems) + if (items != null) { + list.addAll(items) + } + thread.items = list + } + } + inboxManager.setThread(threadId, thread) + } + + private fun setThread(thread: DirectThread) { + setThread(thread, false) + } + + private fun setThreadUsers(users: List?, leftUsers: List?) { + val currentThread = thread.value ?: return + val thread: DirectThread = try { + currentThread.clone() as DirectThread + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "setThreadUsers: ", e) + return + } + if (users != null) { + thread.users = users + } + if (leftUsers != null) { + thread.leftUsers = leftUsers + } + inboxManager.setThread(threadId, thread) + } + + private fun addItems(index: Int, items: Collection) { + inboxManager.addItemsToThread(threadId, index, items) + } + + private fun addReaction(item: DirectItem, emoji: Emoji) { + if (currentUser == null) return + val isLike = emoji.unicode == "❤️" + var reactions = item.reactions + reactions = if (reactions == null) { + DirectItemReactions(null, null) + } else { + try { + reactions.clone() as DirectItemReactions + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "addReaction: ", e) + return + } + } + if (isLike) { + val likes = addEmoji(reactions.likes, null, false) + reactions.likes = likes + } + val emojis = addEmoji(reactions.emojis, emoji.unicode, true) + reactions.emojis = emojis + val currentItems = items.value + val items = if (currentItems == null) LinkedList() else LinkedList(currentItems) + val index = getItemIndex(item, items) + if (index >= 0) { + try { + val clone = items[index].clone() as DirectItem + clone.reactions = reactions + items[index] = clone + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "addReaction: error cloning", e) + } + } + inboxManager.setItemsToThread(threadId, items) + } + + private fun removeReaction(item: DirectItem) { + try { + val itemClone = item.clone() as DirectItem + val reactions = itemClone.reactions + var reactionsClone: DirectItemReactions? = null + if (reactions != null) { + reactionsClone = reactions.clone() as DirectItemReactions + } + var likes: List? = null + if (reactionsClone != null) { + likes = reactionsClone.likes + } + if (likes != null) { + val updatedLikes = likes.stream() + .filter { (senderId) -> senderId != viewerId } + .collect(Collectors.toList()) + if (reactionsClone != null) { + reactionsClone.likes = updatedLikes + } + } + var emojis: List? = null + if (reactionsClone != null) { + emojis = reactionsClone.emojis + } + if (emojis != null) { + val updatedEmojis = emojis.stream() + .filter { (senderId) -> senderId != viewerId } + .collect(Collectors.toList()) + if (reactionsClone != null) { + reactionsClone.emojis = updatedEmojis + } + } + itemClone.reactions = reactionsClone + val items = items.value + val list = if (items == null) LinkedList() else LinkedList(items) + val index = getItemIndex(item, list) + if (index >= 0) { + list[index] = itemClone + } + inboxManager.setItemsToThread(threadId, list) + } catch (e: Exception) { + Log.e(TAG, "removeReaction: ", e) + } + } + + private fun removeItem(item: DirectItem): Int { + val items = items.value + val list = if (items == null) LinkedList() else LinkedList(items) + val index = getItemIndex(item, list) + if (index >= 0) { + list.removeAt(index) + inboxManager.setItemsToThread(threadId, list) + } + return index + } + + private fun addEmoji( + reactionList: List?, + emoji: String?, + shouldReplaceIfAlreadyReacted: Boolean, + ): List? { + if (currentUser == null) return reactionList + val temp: MutableList = if (reactionList == null) ArrayList() else ArrayList(reactionList) + var index = -1 + for (i in temp.indices) { + val (senderId) = temp[i] + if (senderId == currentUser.pk) { + index = i + break + } + } + val reaction = DirectItemEmojiReaction( + currentUser.pk, + System.currentTimeMillis() * 1000, + emoji, + "none" + ) + if (index < 0) { + temp.add(0, reaction) + } else if (shouldReplaceIfAlreadyReacted) { + temp[index] = reaction + } + return temp + } + + fun sendText(text: String, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + val userId = getCurrentUserId(data) ?: return data + val clientContext = UUID.randomUUID().toString() + val replyToItemValue = _replyToItem.value + val directItem = createText(userId, clientContext, text, replyToItemValue) + // Log.d(TAG, "sendText: sending: itemId: " + directItem.getItemId()); + directItem.isPending = true + addItems(0, listOf(directItem)) + data.postValue(loading(directItem)) + val repliedToItemId = replyToItemValue?.itemId + val repliedToClientContext = replyToItemValue?.clientContext + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.broadcastText( + csrfToken, + viewerId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + text, + repliedToItemId, + repliedToClientContext + ) + parseResponse(response, data, directItem) + } catch (e: Exception) { + data.postValue(error(e.message, directItem)) + Log.e(TAG, "sendText: ", e) + } + } + return data + } + + fun sendUri(uri: Uri, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + val mimeType = Utils.getMimeType(uri, contentResolver) + if (isEmpty(mimeType)) { + data.postValue(error("Unknown MediaType", null)) + return data + } + val isPhoto = mimeType != null && mimeType.startsWith("image") + if (isPhoto) { + sendPhoto(data, uri, scope) + return data + } + if (mimeType != null && mimeType.startsWith("video")) { + sendVideo(data, uri, scope) + } + return data + } + + fun sendAnimatedMedia(giphyGif: GiphyGif, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + val userId = getCurrentUserId(data) ?: return data + val clientContext = UUID.randomUUID().toString() + val directItem = createAnimatedMedia(userId, clientContext, giphyGif) + directItem.isPending = true + addItems(0, listOf(directItem)) + data.postValue(loading(directItem)) + scope.launch(Dispatchers.IO) { + try { + val request = directMessagesRepository.broadcastAnimatedMedia( + csrfToken, + userId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + giphyGif + ) + parseResponse(request, data, directItem) + } catch (e: Exception) { + data.postValue(error(e.message, directItem)) + Log.e(TAG, "sendAnimatedMedia: ", e) + } + } + return data + } + + fun sendVoice( + data: MutableLiveData>, + uri: Uri, + waveform: List, + samplingFreq: Int, + duration: Long, + byteLength: Long, + scope: CoroutineScope, + ) { + if (duration > 60000) { + // instagram does not allow uploading audio longer than 60 secs for Direct messages + data.postValue(error(R.string.dms_ERROR_AUDIO_TOO_LONG, null)) + return + } + val userId = getCurrentUserId(data) ?: return + val clientContext = UUID.randomUUID().toString() + val directItem = createVoice(userId, clientContext, uri, duration, waveform, samplingFreq) + directItem.isPending = true + addItems(0, listOf(directItem)) + data.postValue(loading(directItem)) + val uploadDmVoiceOptions = createUploadDmVoiceOptions(byteLength, duration) + scope.launch(Dispatchers.IO) { + try { + val response = uploadVideo(uri, contentResolver, uploadDmVoiceOptions) + // Log.d(TAG, "onUploadComplete: " + response); + if (handleInvalidResponse(data, response)) return@launch + val uploadFinishOptions = UploadFinishOptions( + uploadDmVoiceOptions.uploadId, + "4", + null + ) + mediaRepository.uploadFinish(csrfToken, userId, deviceUuid, uploadFinishOptions) + val broadcastResponse = directMessagesRepository.broadcastVoice( + csrfToken, + viewerId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + uploadDmVoiceOptions.uploadId, + waveform, + samplingFreq + ) + parseResponse(broadcastResponse, data, directItem) + } catch (e: Exception) { + data.postValue(error(e.message, directItem)) + Log.e(TAG, "sendVoice: ", e) + } + } + } + + fun sendReaction( + item: DirectItem, + emoji: Emoji, + scope: CoroutineScope, + ): LiveData> { + val data = MutableLiveData>() + val userId = getCurrentUserId(data) + if (userId == null) { + data.postValue(error("userId is null", null)) + return data + } + val clientContext = UUID.randomUUID().toString() + // Log.d(TAG, "sendText: sending: itemId: " + directItem.getItemId()); + data.postValue(loading(item)) + addReaction(item, emoji) + var emojiUnicode: String? = null + if (emoji.unicode != "❤️") { + emojiUnicode = emoji.unicode + } + val itemId = item.itemId + if (itemId == null) { + data.postValue(error("itemId is null", null)) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.broadcastReaction( + csrfToken, + userId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + itemId, + emojiUnicode, + false + ) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + Log.e(TAG, "sendReaction: ", e) + } + } + return data + } + + fun sendDeleteReaction(itemId: String, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + val item = getItem(itemId) + if (item == null) { + data.postValue(error("Invalid item", null)) + return data + } + val reactions = item.reactions + if (reactions == null) { + // already removed? + data.postValue(success(item)) + return data + } + removeReaction(item) + val clientContext = UUID.randomUUID().toString() + val itemId1 = item.itemId + if (itemId1 == null) { + data.postValue(error("itemId is null", null)) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.broadcastReaction( + csrfToken, + viewerId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + itemId1, + null, + true + ) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + Log.e(TAG, "sendDeleteReaction: ", e) + } + } + return data + } + + fun unsend(item: DirectItem, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + val index = removeItem(item) + val itemId = item.itemId + if (itemId == null) { + data.postValue(error("itemId is null", null)) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.deleteItem(csrfToken, deviceUuid, threadId, itemId) + } catch (e: Exception) { + // add the item back if unsuccessful + addItems(index, listOf(item)) + data.postValue(error(e.message, item)) + Log.e(TAG, "unsend: ", e) + } + } + return data + } + + fun forward( + recipients: Set, + itemToForward: DirectItem, + scope: CoroutineScope, + ) { + for (recipient in recipients) { + forward(recipient, itemToForward, scope) + } + } + + fun forward( + recipient: RankedRecipient, + itemToForward: DirectItem, + scope: CoroutineScope, + ) { + if (recipient.thread == null && recipient.user != null) { + scope.launch(Dispatchers.IO) { + // create thread and forward + val thread = DirectMessagesManager.createThread(recipient.user.pk) + forward(thread, itemToForward, scope) + } + return + } + if (recipient.thread != null) { + // just forward + val thread = recipient.thread + forward(thread, itemToForward, scope) + } + } + + fun setReplyToItem(item: DirectItem?) { + // Log.d(TAG, "setReplyToItem: " + item); + _replyToItem.postValue(item) + } + + private fun forward( + thread: DirectThread, + itemToForward: DirectItem, + scope: CoroutineScope, + ): LiveData> { + val data = MutableLiveData>() + val forwardItemId = itemToForward.itemId + if (forwardItemId == null) { + data.postValue(error("item id is null", null)) + return data + } + val itemType = itemToForward.itemType + if (itemType == null) { + data.postValue(error("item type is null", null)) + return data + } + val itemTypeName = DirectItemType.getName(itemType) + if (itemTypeName == null) { + Log.e(TAG, "forward: itemTypeName was null!") + data.postValue(error("itemTypeName is null", null)) + return data + } + data.postValue(loading(null)) + if (thread.threadId == null) { + Log.e(TAG, "forward: threadId was null!") + data.postValue(error("threadId is null", null)) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.forward( + thread.threadId, + itemTypeName, + threadId, + forwardItemId + ) + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "forward: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun acceptRequest(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.approveRequest(csrfToken, deviceUuid, threadId) + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "acceptRequest: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun declineRequest(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.declineRequest(csrfToken, deviceUuid, threadId) + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "declineRequest: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun refreshChats(scope: CoroutineScope) { + val isFetching = _fetching.value + if (isFetching != null && isFetching.status === Resource.Status.LOADING) { + stopCurrentRequest() + } + cursor = null + hasOlder = true + fetchChats(scope) + } + + private fun sendPhoto( + data: MutableLiveData>, + uri: Uri, + scope: CoroutineScope, + ) { + try { + val dimensions = BitmapUtils.decodeDimensions(contentResolver, uri) + if (dimensions == null) { + data.postValue(error("Decoding dimensions failed", null)) + return + } + sendPhoto(data, uri, dimensions.first, dimensions.second, scope) + } catch (e: IOException) { + data.postValue(error(e.message, null)) + Log.e(TAG, "sendPhoto: ", e) + } + } + + private fun sendPhoto( + data: MutableLiveData>, + uri: Uri, + width: Int, + height: Int, + scope: CoroutineScope, + ) { + val clientContext = UUID.randomUUID().toString() + val directItem = createImageOrVideo(viewerId, clientContext, uri, width, height, false) + directItem.isPending = true + addItems(0, listOf(directItem)) + data.postValue(loading(directItem)) + scope.launch(Dispatchers.IO) { + try { + val response = uploadPhoto(uri, contentResolver) + if (handleInvalidResponse(data, response)) return@launch + val response1 = response.response ?: return@launch + val uploadId = response1.optString("upload_id") + val response2 = directMessagesRepository.broadcastPhoto(csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, uploadId) + parseResponse(response2, data, directItem) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + Log.e(TAG, "sendPhoto: ", e) + } + } + } + + private fun sendVideo( + data: MutableLiveData>, + uri: Uri, + scope: CoroutineScope, + ) { + MediaUtils.getVideoInfo(contentResolver, uri, object : OnInfoLoadListener { + override fun onLoad(info: VideoInfo?) { + if (info == null) { + data.postValue(error("Could not get the video info", null)) + return + } + sendVideo(data, uri, info.size, info.duration, info.width, info.height, scope) + } + + override fun onFailure(t: Throwable) { + data.postValue(error(t.message, null)) + } + }) + } + + private fun sendVideo( + data: MutableLiveData>, + uri: Uri, + byteLength: Long, + duration: Long, + width: Int, + height: Int, + scope: CoroutineScope, + ) { + if (duration > 60000) { + // instagram does not allow uploading videos longer than 60 secs for Direct messages + data.postValue(error(R.string.dms_ERROR_VIDEO_TOO_LONG, null)) + return + } + val userId = getCurrentUserId(data) ?: return + val clientContext = UUID.randomUUID().toString() + val directItem = createImageOrVideo(userId, clientContext, uri, width, height, true) + directItem.isPending = true + addItems(0, listOf(directItem)) + data.postValue(loading(directItem)) + val uploadDmVideoOptions = createUploadDmVideoOptions(byteLength, duration, width, height) + scope.launch(Dispatchers.IO) { + try { + val response = uploadVideo(uri, contentResolver, uploadDmVideoOptions) + // Log.d(TAG, "onUploadComplete: " + response); + if (handleInvalidResponse(data, response)) return@launch + val uploadFinishOptions = UploadFinishOptions( + uploadDmVideoOptions.uploadId, + "2", + VideoOptions(duration / 1000f, emptyList(), 0, false) + ) + mediaRepository.uploadFinish(csrfToken, userId, deviceUuid, uploadFinishOptions) + val broadcastResponse = directMessagesRepository.broadcastVideo( + csrfToken, + viewerId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + uploadDmVideoOptions.uploadId, + "", + true + ) + parseResponse(broadcastResponse, data, directItem) + } catch (e: Exception) { + data.postValue(error(e.message, directItem)) + Log.e(TAG, "sendVideo: ", e) + } + } + } + + private fun parseResponse( + response: DirectThreadBroadcastResponse, + data: MutableLiveData>, + directItem: DirectItem, + ) { + val payloadClientContext: String? + val timestamp: Long + val itemId: String? + val payload = response.payload + if (payload == null) { + val messageMetadata = response.messageMetadata + if (messageMetadata == null || messageMetadata.isEmpty()) { + data.postValue(success(directItem)) + return + } + val (clientContext, itemId1, timestamp1) = messageMetadata[0] + payloadClientContext = clientContext + itemId = itemId1 + timestamp = timestamp1 + } else { + payloadClientContext = payload.clientContext + timestamp = payload.timestamp + itemId = payload.itemId + } + if (payloadClientContext == null) { + data.postValue(error("clientContext in response was null", null)) + return + } + updateItemSent(payloadClientContext, timestamp, itemId) + data.postValue(success(directItem)) + } + + private fun updateItemSent( + clientContext: String, + timestamp: Long, + itemId: String?, + ) { + val items = items.value + val list = if (items == null) LinkedList() else LinkedList(items) + val index = list.indexOfFirst { it?.clientContext == clientContext } + if (index < 0) return + val directItem = list[index] + try { + val itemClone = directItem.clone() as DirectItem + itemClone.itemId = itemId + itemClone.isPending = false + itemClone.setTimestamp(timestamp) + list[index] = itemClone + inboxManager.setItemsToThread(threadId, list) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "updateItemSent: ", e) + } + } + + private fun handleInvalidResponse( + data: MutableLiveData>, + response: MediaUploadResponse, + ): Boolean { + val responseJson = response.response + if (responseJson == null || response.responseCode != HttpURLConnection.HTTP_OK) { + data.postValue(error(R.string.generic_not_ok_response, null)) + return true + } + val status = responseJson.optString("status") + if (isEmpty(status) || status != "ok") { + data.postValue(error(R.string.generic_not_ok_response, null)) + return true + } + return false + } + + private fun getItemIndex(item: DirectItem, list: List): Int { + return Iterables.indexOf(list) { i: DirectItem? -> i != null && i.itemId == item.itemId } + } + + private fun getItem(itemId: String): DirectItem? { + val items = items.value ?: return null + return items.asSequence() + .filter { it.itemId == itemId } + .firstOrNull() + } + + private fun stopCurrentRequest() { + chatsRequest?.let { + if (it.isExecuted || it.isCanceled) return + it.cancel() + } + _fetching.postValue(success(Any())) + } + + private fun getCurrentUserId(data: MutableLiveData>): Long? { + if (currentUser == null || currentUser.pk <= 0) { + data.postValue(error(R.string.dms_ERROR_INVALID_USER, null)) + return null + } + return currentUser.pk + } + + fun removeThread() { + val pendingValue = isPending.value + val threadInPending = pendingValue != null && pendingValue + inboxManager.removeThread(threadId) + if (threadInPending) { + val totalValue = inboxManager.getPendingRequestsTotal().value ?: return + inboxManager.setPendingRequestsTotal(totalValue - 1) + } + } + + fun updateTitle(newTitle: String, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.updateTitle(csrfToken, deviceUuid, threadId, newTitle.trim()) + handleDetailsChangeResponse(data, response) + } catch (e: Exception) { + } + } + return data + } + + fun addMembers(users: Set, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.addUsers( + csrfToken, + deviceUuid, + threadId, + users.map { obj: User -> obj.pk } + ) + handleDetailsChangeResponse(data, response) + } catch (e: Exception) { + Log.e(TAG, "addMembers: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun removeMember(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.removeUsers(csrfToken, deviceUuid, threadId, setOf(user.pk)) + data.postValue(success(Any())) + var activeUsers = users.value + var leftUsersValue = leftUsers.value + if (activeUsers == null) { + activeUsers = emptyList() + } + if (leftUsersValue == null) { + leftUsersValue = emptyList() + } + val updatedActiveUsers = activeUsers.filter { u: User -> u.pk != user.pk } + val updatedLeftUsersBuilder = ImmutableList.builder().addAll(leftUsersValue) + if (!leftUsersValue.contains(user)) { + updatedLeftUsersBuilder.add(user) + } + val updatedLeftUsers = updatedLeftUsersBuilder.build() + setThreadUsers(updatedActiveUsers, updatedLeftUsers) + } catch (e: Exception) { + Log.e(TAG, "removeMember: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun isAdmin(user: User): Boolean { + val adminUserIdsValue = adminUserIds.value + return adminUserIdsValue != null && adminUserIdsValue.contains(user.pk) + } + + fun makeAdmin(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + if (isAdmin(user)) return data + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.addAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) + val currentAdminIds = adminUserIds.value + val updatedAdminIds = ImmutableList.builder() + .addAll(currentAdminIds ?: emptyList()) + .add(user.pk) + .build() + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.adminUserIds = updatedAdminIds + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "makeAdmin: ", e) + } + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "makeAdmin: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun removeAdmin(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + if (!isAdmin(user)) return data + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.removeAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) + val currentAdmins = adminUserIds.value ?: return@launch + val updatedAdminUserIds = currentAdmins.filter { userId1: Long -> userId1 != user.pk } + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.adminUserIds = updatedAdminUserIds + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "removeAdmin: ", e) + } + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "removeAdmin: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun mute(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val muted = isMuted.value + if (muted != null && muted) { + data.postValue(success(Any())) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.mute(csrfToken, deviceUuid, threadId) + data.postValue(success(Any())) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.muted = true + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "mute: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "mute: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun unmute(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val muted = isMuted.value + if (muted != null && !muted) { + data.postValue(success(Any())) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.unmute(csrfToken, deviceUuid, threadId) + data.postValue(success(Any())) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.muted = false + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "unmute: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "unmute: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun muteMentions(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val mentionsMuted = isMentionsMuted.value + if (mentionsMuted != null && mentionsMuted) { + data.postValue(success(Any())) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.muteMentions(csrfToken, deviceUuid, threadId) + data.postValue(success(Any())) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.mentionsMuted = true + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "muteMentions: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "muteMentions: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun unmuteMentions(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val mentionsMuted = isMentionsMuted.value + if (mentionsMuted != null && !mentionsMuted) { + data.postValue(success(Any())) + return data + } + scope.launch(Dispatchers.IO) { + try { + directMessagesRepository.unmuteMentions(csrfToken, deviceUuid, threadId) + data.postValue(success(Any())) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.mentionsMuted = false + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "unmuteMentions: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "unmuteMentions: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun blockUser(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + friendshipRepository.changeBlock(csrfToken, viewerId, deviceUuid, false, user.pk) + refreshChats(scope) + } catch (e: Exception) { + Log.e(TAG, "onFailure: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun unblockUser(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + friendshipRepository.changeBlock(csrfToken, viewerId, deviceUuid, true, user.pk) + refreshChats(scope) + } catch (e: Exception) { + Log.e(TAG, "onFailure: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun restrictUser(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + friendshipRepository.toggleRestrict(csrfToken, deviceUuid, user.pk, true) + refreshChats(scope) + } catch (e: Exception) { + Log.e(TAG, "onFailure: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun unRestrictUser(user: User, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + scope.launch(Dispatchers.IO) { + try { + friendshipRepository.toggleRestrict(csrfToken, deviceUuid, user.pk, false) + refreshChats(scope) + } catch (e: Exception) { + Log.e(TAG, "onFailure: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun approveUsers(users: List, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.approveParticipantRequests( + csrfToken, + deviceUuid, + threadId, + users.map { obj: User -> obj.pk } + ) + handleDetailsChangeResponse(data, response) + pendingUserApproveDenySuccessAction(users) + } catch (e: Exception) { + Log.e(TAG, "approveUsers: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun denyUsers(users: List, scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.declineParticipantRequests( + csrfToken, + deviceUuid, + threadId, + users.map { obj: User -> obj.pk } + ) + handleDetailsChangeResponse(data, response) + pendingUserApproveDenySuccessAction(users) + } catch (e: Exception) { + Log.e(TAG, "denyUsers: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + private fun pendingUserApproveDenySuccessAction(users: List) { + val pendingRequestsValue = _pendingRequests.value ?: return + val pendingUsers = pendingRequestsValue.users + if (pendingUsers == null || pendingUsers.isEmpty()) return + val filtered = pendingUsers.filter { o: User -> !users.contains(o) } + try { + val clone = pendingRequestsValue.clone() as DirectThreadParticipantRequestsResponse + clone.users = filtered + val totalParticipantRequests = clone.totalParticipantRequests + clone.totalParticipantRequests = if (totalParticipantRequests > 0) totalParticipantRequests - 1 else 0 + _pendingRequests.postValue(clone) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "pendingUserApproveDenySuccessAction: ", e) + } + } + + fun approvalRequired(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val approvalRequiredToJoin = isApprovalRequiredToJoin.value + if (approvalRequiredToJoin != null && approvalRequiredToJoin) { + data.postValue(success(Any())) + return data + } + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.approvalRequired(csrfToken, deviceUuid, threadId) + handleDetailsChangeResponse(data, response) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.approvalRequiredForNewMembers = true + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "onResponse: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "approvalRequired: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun approvalNotRequired(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val approvalRequiredToJoin = isApprovalRequiredToJoin.value + if (approvalRequiredToJoin != null && !approvalRequiredToJoin) { + data.postValue(success(Any())) + return data + } + scope.launch(Dispatchers.IO) { + try { + val request = directMessagesRepository.approvalNotRequired(csrfToken, deviceUuid, threadId) + handleDetailsChangeResponse(data, request) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.approvalRequiredForNewMembers = false + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "onResponse: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "approvalNotRequired: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun leave(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + val request = directMessagesRepository.leave(csrfToken, deviceUuid, threadId) + handleDetailsChangeResponse(data, request) + } catch (e: Exception) { + Log.e(TAG, "leave: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun end(scope: CoroutineScope): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + val request = directMessagesRepository.end(csrfToken, deviceUuid, threadId) + handleDetailsChangeResponse(data, request) + val currentThread = thread.value ?: return@launch + try { + val thread = currentThread.clone() as DirectThread + thread.inputMode = 1 + inboxManager.setThread(threadId, thread) + } catch (e: CloneNotSupportedException) { + Log.e(TAG, "onResponse: ", e) + } + } catch (e: Exception) { + Log.e(TAG, "leave: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + private fun handleDetailsChangeResponse( + data: MutableLiveData>, + response: DirectThreadDetailsChangeResponse, + ) { + data.postValue(success(Any())) + val thread = response.thread + if (thread != null) { + setThread(thread, true) + } + } + + fun markAsSeen( + directItem: DirectItem, + scope: CoroutineScope, + ): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + scope.launch(Dispatchers.IO) { + try { + val response = directMessagesRepository.markAsSeen(csrfToken, deviceUuid, threadId, directItem) + if (response == null) { + data.postValue(error(R.string.generic_null_response, null)) + return@launch + } + if (currentUser == null) return@launch + inboxManager.fetchUnseenCount(scope) + val payload = response.payload ?: return@launch + val timestamp = payload.timestamp + val thread = thread.value ?: return@launch + val currentLastSeenAt = thread.lastSeenAt + val lastSeenAt = if (currentLastSeenAt == null) HashMap() else HashMap(currentLastSeenAt) + lastSeenAt[currentUser.pk] = DirectThreadLastSeenAt(timestamp, directItem.itemId) + thread.lastSeenAt = lastSeenAt + setThread(thread, true) + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "markAsSeen: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/Comment.kt b/app/src/main/java/awais/instagrabber/models/Comment.kt new file mode 100644 index 0000000..4d85e33 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/Comment.kt @@ -0,0 +1,66 @@ +package awais.instagrabber.models + +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.utils.TextUtils +import java.io.Serializable +import java.util.* + +class Comment( + val pk: String, + val text: String, + val createdAt: Long, + var commentLikeCount: Long, + private var hasLikedComment: Boolean, + val user: User, + val childCommentCount: Int +) : Serializable, Cloneable { + val dateTime: String + get() = TextUtils.epochSecondToString(createdAt) + + fun getLiked(): Boolean { + return hasLikedComment + } + + fun setLiked(hasLikedComment: Boolean) { + commentLikeCount = if (hasLikedComment) commentLikeCount + 1 else commentLikeCount - 1 + this.hasLikedComment = hasLikedComment + } + + @Throws(CloneNotSupportedException::class) + public override fun clone(): Any { + return super.clone() + } + + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Comment + + if (pk != other.pk) return false + if (text != other.text) return false + if (createdAt != other.createdAt) return false + if (commentLikeCount != other.commentLikeCount) return false + if (hasLikedComment != other.hasLikedComment) return false + if (user != other.user) return false + if (childCommentCount != other.childCommentCount) return false + + return true + } + + override fun hashCode(): Int { + var result = pk.hashCode() + result = 31 * result + text.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + commentLikeCount.hashCode() + result = 31 * result + hasLikedComment.hashCode() + result = 31 * result + user.hashCode() + result = 31 * result + childCommentCount + return result + } + + override fun toString(): String { + return "Comment(pk='$pk', text='$text', createdAt=$createdAt, commentLikeCount=$commentLikeCount, hasLikedComment=$hasLikedComment, user=$user, childCommentCount=$childCommentCount)" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/IntentModel.kt b/app/src/main/java/awais/instagrabber/models/IntentModel.kt new file mode 100644 index 0000000..a07d9b7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/IntentModel.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.models + +import awais.instagrabber.models.enums.IntentModelType + +data class IntentModel(val type: IntentModelType, val text: String) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/PostsLayoutPreferences.java b/app/src/main/java/awais/instagrabber/models/PostsLayoutPreferences.java new file mode 100644 index 0000000..e24b137 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/PostsLayoutPreferences.java @@ -0,0 +1,228 @@ +package awais.instagrabber.models; + +import com.google.gson.Gson; + +import java.util.Objects; + +public final class PostsLayoutPreferences { + private final PostsLayoutType type; + private final int colCount; + private final boolean isAvatarVisible; + private final boolean isNameVisible; + private final ProfilePicSize profilePicSize; + private final boolean hasRoundedCorners; + private final boolean hasGap; + private final boolean animationDisabled; + + public static class Builder { + private PostsLayoutType type = PostsLayoutType.GRID; + private int colCount = 3; + private boolean isAvatarVisible = true; + private boolean isNameVisible = false; + private ProfilePicSize profilePicSize = ProfilePicSize.SMALL; + private boolean hasRoundedCorners = true; + private boolean hasGap = true; + private boolean animationDisabled = false; + + public Builder setType(final PostsLayoutType type) { + this.type = type; + return this; + } + + public Builder setColCount(final int colCount) { + this.colCount = (colCount <= 0 || colCount > 3) ? 1 : colCount; + return this; + } + + public Builder setAvatarVisible(final boolean avatarVisible) { + this.isAvatarVisible = avatarVisible; + return this; + } + + public Builder setNameVisible(final boolean nameVisible) { + this.isNameVisible = nameVisible; + return this; + } + + public Builder setProfilePicSize(final ProfilePicSize profilePicSize) { + this.profilePicSize = profilePicSize; + return this; + } + + public Builder setHasRoundedCorners(final boolean hasRoundedCorners) { + this.hasRoundedCorners = hasRoundedCorners; + return this; + } + + public Builder setHasGap(final boolean hasGap) { + this.hasGap = hasGap; + return this; + } + + public Builder setAnimationDisabled(final boolean animationDisabled) { + this.animationDisabled = animationDisabled; + return this; + } + + // Breaking builder pattern and adding getters to avoid too many object creations in PostsLayoutPreferencesDialogFragment + public PostsLayoutType getType() { + return type; + } + + public int getColCount() { + return colCount; + } + + public boolean isAvatarVisible() { + return isAvatarVisible; + } + + public boolean isNameVisible() { + return isNameVisible; + } + + public ProfilePicSize getProfilePicSize() { + return profilePicSize; + } + + public boolean getHasRoundedCorners() { + return hasRoundedCorners; + } + + public boolean getHasGap() { + return hasGap; + } + + public boolean isAnimationDisabled() { + return animationDisabled; + } + + public Builder mergeFrom(final PostsLayoutPreferences preferences) { + if (preferences == null) { + return this; + } + setColCount(preferences.getColCount()); + setAvatarVisible(preferences.isAvatarVisible()); + setNameVisible(preferences.isNameVisible()); + setType(preferences.getType()); + setProfilePicSize(preferences.getProfilePicSize()); + setHasRoundedCorners(preferences.getHasRoundedCorners()); + setHasGap(preferences.getHasGap()); + setAnimationDisabled(preferences.isAnimationDisabled()); + return this; + } + + public PostsLayoutPreferences build() { + return new PostsLayoutPreferences(type, colCount, isAvatarVisible, isNameVisible, profilePicSize, hasRoundedCorners, hasGap, + animationDisabled); + } + } + + public static Builder builder() { + return new Builder(); + } + + private PostsLayoutPreferences(final PostsLayoutType type, + final int colCount, + final boolean isAvatarVisible, + final boolean isNameVisible, + final ProfilePicSize profilePicSize, + final boolean hasRoundedCorners, + final boolean hasGap, + final boolean animationDisabled) { + + this.type = type; + this.colCount = colCount; + this.isAvatarVisible = isAvatarVisible; + this.isNameVisible = isNameVisible; + this.profilePicSize = profilePicSize; + this.hasRoundedCorners = hasRoundedCorners; + this.hasGap = hasGap; + this.animationDisabled = animationDisabled; + } + + public PostsLayoutType getType() { + return type; + } + + public int getColCount() { + return colCount; + } + + public boolean isAvatarVisible() { + return isAvatarVisible; + } + + public boolean isNameVisible() { + return isNameVisible; + } + + public ProfilePicSize getProfilePicSize() { + return profilePicSize; + } + + public boolean getHasRoundedCorners() { + return hasRoundedCorners; + } + + public boolean getHasGap() { + return hasGap; + } + + public String getJson() { + return new Gson().toJson(this); + } + + public static PostsLayoutPreferences fromJson(final String json) { + if (json == null) return null; + return new Gson().fromJson(json, PostsLayoutPreferences.class); + } + + public boolean isAnimationDisabled() { + return animationDisabled; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final PostsLayoutPreferences that = (PostsLayoutPreferences) o; + return colCount == that.colCount && + isAvatarVisible == that.isAvatarVisible && + isNameVisible == that.isNameVisible && + type == that.type && + profilePicSize == that.profilePicSize && + animationDisabled == that.animationDisabled; + } + + @Override + public int hashCode() { + return Objects.hash(type, colCount, isAvatarVisible, isNameVisible, profilePicSize, animationDisabled); + } + + @Override + public String toString() { + return "PostsLayoutPreferences{" + + "type=" + type + + ", colCount=" + colCount + + ", isAvatarVisible=" + isAvatarVisible + + ", isNameVisible=" + isNameVisible + + ", profilePicSize=" + profilePicSize + + ", hasRoundedCorners=" + hasRoundedCorners + + ", hasGap=" + hasGap + + ", animationDisabled=" + animationDisabled + + '}'; + } + + public enum PostsLayoutType { + GRID, + STAGGERED_GRID, + LINEAR + } + + public enum ProfilePicSize { + REGULAR, + SMALL, + TINY + } +} diff --git a/app/src/main/java/awais/instagrabber/models/Resource.kt b/app/src/main/java/awais/instagrabber/models/Resource.kt new file mode 100644 index 0000000..4877c3d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/Resource.kt @@ -0,0 +1,36 @@ +package awais.instagrabber.models + +import androidx.annotation.StringRes + +data class Resource( + @JvmField val status: Status, + @JvmField val data: T? = null, + @JvmField val message: String? = null, + @JvmField @StringRes val resId: Int = 0, +) { + enum class Status { + SUCCESS, ERROR, LOADING + } + + companion object { + @JvmStatic + fun success(data: T): Resource { + return Resource(Status.SUCCESS, data, null, 0) + } + + @JvmStatic + fun error(msg: String?, data: T?): Resource { + return Resource(Status.ERROR, data, msg, 0) + } + + @JvmStatic + fun error(resId: Int, data: T?): Resource { + return Resource(Status.ERROR, data, null, resId) + } + + @JvmStatic + fun loading(data: T?): Resource { + return Resource(Status.LOADING, data, null, 0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/SavedImageEditState.kt b/app/src/main/java/awais/instagrabber/models/SavedImageEditState.kt new file mode 100644 index 0000000..5bab754 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/SavedImageEditState.kt @@ -0,0 +1,13 @@ +package awais.instagrabber.models + +import android.graphics.RectF +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper +import awais.instagrabber.utils.SerializablePair +import java.util.* + +data class SavedImageEditState(val sessionId: String, val originalPath: String) { + var cropImageMatrixValues: FloatArray? = null // 9 values of matrix + var cropRect: RectF? = null + var appliedTuningFilters: HashMap>? = null + var appliedFilter: SerializablePair>? = null +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/Tab.kt b/app/src/main/java/awais/instagrabber/models/Tab.kt new file mode 100644 index 0000000..d31352a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/Tab.kt @@ -0,0 +1,30 @@ +package awais.instagrabber.models + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.NavigationRes + +data class Tab( + @param:DrawableRes val iconResId: Int, + val title: String, + val isRemovable: Boolean, + + /** + * This is the actual resource id of the navigation resource (R.navigation.graphName = navigationResId) + */ + @param:NavigationRes val navigationResId: Int, + + /** + * This is the resource id of the root navigation tag of the navigation resource. + * + * eg: inside R.navigation.direct_messages_nav_graph, the id of the root tag is R.id.direct_messages_nav_graph. + * + * So this field would equal to the value of R.id.direct_messages_nav_graph + */ + @param:IdRes val navigationRootId: Int, + + /** + * This is the start destination of the nav graph + */ + @param:IdRes val startDestinationFragmentId: Int, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/UploadPhotoOptions.kt b/app/src/main/java/awais/instagrabber/models/UploadPhotoOptions.kt new file mode 100644 index 0000000..47d9f4e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/UploadPhotoOptions.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.models + +data class UploadPhotoOptions( + val uploadId: String? = null, + val name: String, + val byteLength: Long = 0, + val isSideCar: Boolean = false, + val waterfallId: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/UploadVideoOptions.kt b/app/src/main/java/awais/instagrabber/models/UploadVideoOptions.kt new file mode 100644 index 0000000..d0baa4e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/UploadVideoOptions.kt @@ -0,0 +1,22 @@ +package awais.instagrabber.models + +import awais.instagrabber.models.enums.MediaItemType + +data class UploadVideoOptions( + val uploadId: String, + val name: String, + val byteLength: Long = 0, + val duration: Long = 0, + val width: Int = 0, + val height: Int = 0, + val isSideCar: Boolean = false, + // Stories + val forAlbum: Boolean = false, + val isDirect: Boolean = false, + val isDirectVoice: Boolean = false, + val isForDirectStory: Boolean = false, + val isIgtvVideo: Boolean = false, + val waterfallId: String? = null, + val offset: Long = 0, + val mediaType: MediaItemType? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/BroadcastItemType.kt b/app/src/main/java/awais/instagrabber/models/enums/BroadcastItemType.kt new file mode 100644 index 0000000..f5578e0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/BroadcastItemType.kt @@ -0,0 +1,15 @@ +package awais.instagrabber.models.enums + +enum class BroadcastItemType(val value: String) { + TEXT("text"), + REACTION("reaction"), + REELSHARE("reel_share"), + IMAGE("configure_photo"), + LINK("link"), + VIDEO("configure_video"), + VOICE("share_voice"), + ANIMATED_MEDIA("animated_media"), + MEDIA_SHARE("media_share"), + PROFILE("profile"), + STORY("story_share"), // not reply +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.kt b/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.kt new file mode 100755 index 0000000..a1e8a29 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.kt @@ -0,0 +1,100 @@ +package awais.instagrabber.models.enums + +import com.google.gson.annotations.SerializedName +import java.io.Serializable + +enum class DirectItemType(val id: Int) : Serializable { + UNKNOWN(0), + + @SerializedName("text") + TEXT(1), + + @SerializedName("like") + LIKE(2), + + @SerializedName("link") + LINK(3), + + @SerializedName("media") + MEDIA(4), + + @SerializedName("raven_media") + RAVEN_MEDIA(5), + + @SerializedName("profile") + PROFILE(6), + + @SerializedName("video_call_event") + VIDEO_CALL_EVENT(7), + + @SerializedName("animated_media") + ANIMATED_MEDIA(8), + + @SerializedName("voice_media") + VOICE_MEDIA(9), + + @SerializedName("media_share") + MEDIA_SHARE(10), + + @SerializedName("reel_share") + REEL_SHARE(11), + + @SerializedName("action_log") + ACTION_LOG(12), + + @SerializedName("placeholder") + PLACEHOLDER(13), + + @SerializedName("story_share") + STORY_SHARE(14), + + @SerializedName("clip") + CLIP(15), // media_share but reel + + @SerializedName("felix_share") + FELIX_SHARE(16), // media_share but igtv + + @SerializedName("location") + LOCATION(17), + + @SerializedName("xma") + XMA(18); // self avatar stickers + + companion object { + private val map: MutableMap = mutableMapOf() + + @JvmStatic + fun getTypeFromId(id: Int): DirectItemType { + return map[id] ?: UNKNOWN + } + + fun getName(directItemType: DirectItemType): String? { + when (directItemType) { + TEXT -> return "text" + LIKE -> return "like" + LINK -> return "link" + MEDIA -> return "media" + RAVEN_MEDIA -> return "raven_media" + PROFILE -> return "profile" + VIDEO_CALL_EVENT -> return "video_call_event" + ANIMATED_MEDIA -> return "animated_media" + VOICE_MEDIA -> return "voice_media" + MEDIA_SHARE -> return "media_share" + REEL_SHARE -> return "reel_share" + ACTION_LOG -> return "action_log" + PLACEHOLDER -> return "placeholder" + STORY_SHARE -> return "story_share" + CLIP -> return "clip" + FELIX_SHARE -> return "felix_share" + LOCATION -> return "location" + else -> return null + } + } + + init { + for (type in values()) { + map[type.id] = type + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.kt b/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.kt new file mode 100644 index 0000000..b452b1b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.models.enums + +enum class FavoriteType { + TOP, // used just for searching + USER, + HASHTAG, + LOCATION, +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/FollowingType.kt b/app/src/main/java/awais/instagrabber/models/enums/FollowingType.kt new file mode 100755 index 0000000..5a90847 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/FollowingType.kt @@ -0,0 +1,24 @@ +package awais.instagrabber.models.enums + +import java.io.Serializable +import java.util.* + +enum class FollowingType(val id: Int) : Serializable { + FOLLOWING(1), + NOT_FOLLOWING(0); + + companion object { + private val map: MutableMap = mutableMapOf() + + @JvmStatic + fun valueOf(id: Int): FollowingType? { + return map[id] + } + + init { + for (type in values()) { + map[type.id] = type + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/IntentModelType.kt b/app/src/main/java/awais/instagrabber/models/enums/IntentModelType.kt new file mode 100755 index 0000000..c39e2c1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/IntentModelType.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.models.enums + +enum class IntentModelType { + UNKNOWN, + USERNAME, + POST, + HASHTAG, + LOCATION, +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/MediaItemType.kt b/app/src/main/java/awais/instagrabber/models/enums/MediaItemType.kt new file mode 100755 index 0000000..f4d023e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/MediaItemType.kt @@ -0,0 +1,26 @@ +package awais.instagrabber.models.enums + +import java.io.Serializable + +enum class MediaItemType(val id: Int) : Serializable { + MEDIA_TYPE_IMAGE(1), + MEDIA_TYPE_VIDEO(2), + MEDIA_TYPE_SLIDER(8), + MEDIA_TYPE_VOICE(11), + MEDIA_TYPE_LIVE(5); // arbitrary + + companion object { + private val map: MutableMap = mutableMapOf() + + @JvmStatic + fun valueOf(id: Int): MediaItemType? { + return map[id] + } + + init { + for (type in values()) { + map[type.id] = type + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/NotificationType.kt b/app/src/main/java/awais/instagrabber/models/enums/NotificationType.kt new file mode 100755 index 0000000..1b1bfaa --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/NotificationType.kt @@ -0,0 +1,31 @@ +package awais.instagrabber.models.enums + +import java.io.Serializable + +enum class NotificationType(val itemType: Int) : Serializable { + LIKE(60), + FOLLOW(101), + COMMENT(12), // NOT TESTED + COMMENT_MENTION(66), + TAGGED(102), // NOT TESTED + COMMENT_LIKE(13), + TAGGED_COMMENT(14), + RESPONDED_STORY(213), + REQUEST(75), + AYML(9999); + + companion object { + private val map: MutableMap = mutableMapOf() + + @JvmStatic + fun valueOfType(itemType: Int): NotificationType? { + return map[itemType] + } + + init { + for (type in values()) { + map[type.itemType] = type + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/PostItemType.kt b/app/src/main/java/awais/instagrabber/models/enums/PostItemType.kt new file mode 100644 index 0000000..58bedc1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/PostItemType.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.models.enums + +import java.io.Serializable + +enum class PostItemType : Serializable { + MAIN, DISCOVER, FEED, SAVED, COLLECTION, LIKED, TAGGED, HASHTAG, LOCATION +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/RavenMediaViewMode.kt b/app/src/main/java/awais/instagrabber/models/enums/RavenMediaViewMode.kt new file mode 100644 index 0000000..2707d2f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/RavenMediaViewMode.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.models.enums + +import com.google.gson.annotations.SerializedName + +enum class RavenMediaViewMode { + @SerializedName("permanent") + PERMANENT, + @SerializedName("replayable") + REPLAYABLE, + @SerializedName("once") + ONCE, +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/StoryPaginationType.kt b/app/src/main/java/awais/instagrabber/models/enums/StoryPaginationType.kt new file mode 100644 index 0000000..5bdb4f8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/models/enums/StoryPaginationType.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.models.enums + +import java.io.Serializable + +enum class StoryPaginationType : Serializable { + FORWARD, BACKWARD, DO_NOTHING, ERROR +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/CollectionRepository.java b/app/src/main/java/awais/instagrabber/repositories/CollectionRepository.java new file mode 100644 index 0000000..3afca7e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/CollectionRepository.java @@ -0,0 +1,23 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.UserFeedResponse; +import awais.instagrabber.repositories.responses.WrappedFeedResponse; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.QueryMap; + +public interface CollectionRepository { + + @FormUrlEncoded + @POST("/api/v1/collections/{id}/{action}/") + Call changeCollection(@Path("id") String id, + @Path("action") String action, + @FieldMap Map signedForm); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/CommentRepository.java b/app/src/main/java/awais/instagrabber/repositories/CommentRepository.java new file mode 100644 index 0000000..38c64b3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/CommentRepository.java @@ -0,0 +1,49 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.CommentsFetchResponse; +import awais.instagrabber.repositories.responses.ChildCommentsFetchResponse; +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.Query; +import retrofit2.http.QueryMap; + +public interface CommentRepository { + @GET("/api/v1/media/{mediaId}/comments/") + Call fetchComments(@Path("mediaId") final String mediaId, + @QueryMap final Map queryMap); + + @GET("/api/v1/media/{mediaId}/comments/{commentId}/inline_child_comments/") + Call fetchChildComments(@Path("mediaId") final String mediaId, + @Path("commentId") final String commentId, + @QueryMap final Map queryMap); + + @FormUrlEncoded + @POST("/api/v1/media/{mediaId}/comment/") + Call comment(@Path("mediaId") final String mediaId, + @FieldMap final Map signedForm); + + @FormUrlEncoded + @POST("/api/v1/media/{mediaId}/comment/bulk_delete/") + Call commentsBulkDelete(@Path("mediaId") final String mediaId, + @FieldMap final Map signedForm); + + @FormUrlEncoded + @POST("/api/v1/media/{commentId}/comment_like/") + Call commentLike(@Path("commentId") final String commentId, + @FieldMap final Map signedForm); + + @FormUrlEncoded + @POST("/api/v1/media/{commentId}/comment_unlike/") + Call commentUnlike(@Path("commentId") final String commentId, + @FieldMap final Map signedForm); + + @GET("/api/v1/language/translate/") + Call translate(@QueryMap final Map form); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt new file mode 100644 index 0000000..7d186fc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt @@ -0,0 +1,181 @@ +package awais.instagrabber.repositories + +import awais.instagrabber.repositories.responses.directmessages.* +import retrofit2.http.* + +interface DirectMessagesService { + @GET("/api/v1/direct_v2/inbox/") + suspend fun fetchInbox(@QueryMap queryMap: Map): DirectInboxResponse + + @GET("/api/v1/direct_v2/pending_inbox/") + suspend fun fetchPendingInbox(@QueryMap queryMap: Map): DirectInboxResponse + + @GET("/api/v1/direct_v2/threads/{threadId}/") + suspend fun fetchThread( + @Path("threadId") threadId: String, + @QueryMap queryMap: Map, + ): DirectThreadFeedResponse + + @GET("/api/v1/direct_v2/get_badge_count/?no_raven=1") + suspend fun fetchUnseenCount(): DirectBadgeCount + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/broadcast/{item}/") + suspend fun broadcast( + @Path("item") item: String, + @FieldMap signedForm: Map, + ): DirectThreadBroadcastResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/add_user/") + suspend fun addUsers( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/remove_users/") + suspend fun removeUsers( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/update_title/") + suspend fun updateTitle( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/add_admins/") + suspend fun addAdmins( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/remove_admins/") + suspend fun removeAdmins( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/items/{itemId}/delete/") + suspend fun deleteItem( + @Path("threadId") threadId: String, + @Path("itemId") itemId: String, + @FieldMap form: Map, + ): String + + @GET("/api/v1/direct_v2/ranked_recipients/") + suspend fun rankedRecipients(@QueryMap queryMap: Map): RankedRecipientsResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/broadcast/forward/") + suspend fun forward(@FieldMap form: Map): DirectThreadBroadcastResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/create_group_thread/") + suspend fun createThread(@FieldMap signedForm: Map): DirectThread + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/mute/") + suspend fun mute( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/unmute/") + suspend fun unmute( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/mute_mentions/") + suspend fun muteMentions( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/unmute_mentions/") + suspend fun unmuteMentions( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @GET("/api/v1/direct_v2/threads/{threadId}/participant_requests/") + suspend fun participantRequests( + @Path("threadId") threadId: String, + @Query("page_size") pageSize: Int, + @Query("cursor") cursor: String?, + ): DirectThreadParticipantRequestsResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/approve_participant_requests/") + suspend fun approveParticipantRequests( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/deny_participant_requests/") + suspend fun declineParticipantRequests( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/approval_required_for_new_members/") + suspend fun approvalRequired( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/approval_not_required_for_new_members/") + suspend fun approvalNotRequired( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/leave/") + suspend fun leave( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/remove_all_users/") + suspend fun end( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): DirectThreadDetailsChangeResponse + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/approve/") + suspend fun approveRequest( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/decline/") + suspend fun declineRequest( + @Path("threadId") threadId: String, + @FieldMap form: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/items/{itemId}/seen/") + suspend fun markItemSeen( + @Path("threadId") threadId: String, + @Path("itemId") itemId: String, + @FieldMap form: Map, + ): DirectItemSeenResponse +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/DiscoverRepository.java b/app/src/main/java/awais/instagrabber/repositories/DiscoverRepository.java new file mode 100644 index 0000000..aed6d4c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/DiscoverRepository.java @@ -0,0 +1,13 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.QueryMap; + +public interface DiscoverRepository { + @GET("/api/v1/discover/topical_explore/") + Call topicalExplore(@QueryMap Map queryParams); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/FeedRepository.java b/app/src/main/java/awais/instagrabber/repositories/FeedRepository.java new file mode 100644 index 0000000..b5273e3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/FeedRepository.java @@ -0,0 +1,15 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.feed.FeedFetchResponse; +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.POST; + +public interface FeedRepository { + @FormUrlEncoded + @POST("/api/v1/feed/timeline/") + Call fetch(@FieldMap final Map signedForm); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/FriendshipService.kt b/app/src/main/java/awais/instagrabber/repositories/FriendshipService.kt new file mode 100644 index 0000000..7bff0c7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/FriendshipService.kt @@ -0,0 +1,37 @@ +package awais.instagrabber.repositories + +import awais.instagrabber.repositories.responses.FriendshipChangeResponse +import awais.instagrabber.repositories.responses.FriendshipListFetchResponse +import awais.instagrabber.repositories.responses.FriendshipRestrictResponse +import retrofit2.http.* + +interface FriendshipService { + @FormUrlEncoded + @POST("/api/v1/friendships/{action}/{id}/") + suspend fun change( + @Path("action") action: String, + @Path("id") id: Long, + @FieldMap form: Map, + ): FriendshipChangeResponse + + @FormUrlEncoded + @POST("/api/v1/restrict_action/{action}/") + suspend fun toggleRestrict( + @Path("action") action: String, + @FieldMap form: Map, + ): FriendshipRestrictResponse + + @GET("/api/v1/friendships/{userId}/{type}/") + suspend fun getList( + @Path("userId") userId: Long, + @Path("type") type: String, // following or followers + @QueryMap(encoded = true) queryParams: Map, + ): FriendshipListFetchResponse + + @FormUrlEncoded + @POST("/api/v1/friendships/{action}/") + suspend fun changeMute( + @Path("action") action: String, + @FieldMap form: Map, + ): FriendshipChangeResponse +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/GifRepository.java b/app/src/main/java/awais/instagrabber/repositories/GifRepository.java new file mode 100644 index 0000000..cf99e59 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/GifRepository.java @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories; + +import awais.instagrabber.repositories.responses.giphy.GiphyGifResponse; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Query; + +public interface GifRepository { + + @GET("/api/v1/creatives/story_media_search_keyed_format/") + Call searchGiphyGifs(@Query("request_surface") final String requestSurface, + @Query("q") final String query, + @Query("media_types") final String mediaTypes); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/GraphQLService.kt b/app/src/main/java/awais/instagrabber/repositories/GraphQLService.kt new file mode 100644 index 0000000..0e57efa --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/GraphQLService.kt @@ -0,0 +1,22 @@ +package awais.instagrabber.repositories + +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.QueryMap + +interface GraphQLService { + @GET("/graphql/query/") + suspend fun fetch(@QueryMap(encoded = true) queryParams: Map): String + + @GET("/{username}/") + suspend fun getUser(@Path("username") username: String): String + + @GET("/p/{shortcode}/?__a=1") + suspend fun getPost(@Path("shortcode") shortcode: String): String + + @GET("/explore/tags/{tag}/") + suspend fun getTag(@Path("tag") tag: String): String + + @GET("/explore/locations/{locationId}/") + suspend fun getLocation(@Path("locationId") locationId: Long): String +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/LocationRepository.java b/app/src/main/java/awais/instagrabber/repositories/LocationRepository.java new file mode 100644 index 0000000..69e7302 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/LocationRepository.java @@ -0,0 +1,19 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.LocationFeedResponse; +import awais.instagrabber.repositories.responses.Place; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Path; +import retrofit2.http.QueryMap; + +public interface LocationRepository { + @GET("/api/v1/locations/{location}/info/") + Call fetch(@Path("location") final long locationId); + + @GET("/api/v1/feed/location/{location}/") + Call fetchPosts(@Path("location") final long locationId, + @QueryMap Map queryParams); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/MediaService.kt b/app/src/main/java/awais/instagrabber/repositories/MediaService.kt new file mode 100644 index 0000000..968cd15 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/MediaService.kt @@ -0,0 +1,57 @@ +package awais.instagrabber.repositories + +import awais.instagrabber.repositories.responses.LikersResponse +import awais.instagrabber.repositories.responses.MediaInfoResponse +import retrofit2.http.* + +interface MediaService { + @GET("/api/v1/media/{mediaId}/info/") + suspend fun fetch(@Path("mediaId") mediaId: Long): MediaInfoResponse + + @GET("/api/v1/media/{mediaId}/{action}/") + suspend fun fetchLikes( + @Path("mediaId") mediaId: String, // one of "likers" or "comment_likers" + @Path("action") action: String, + ): LikersResponse + + @FormUrlEncoded + @POST("/api/v1/media/{mediaId}/{action}/") + suspend fun action( + @Path("action") action: String, + @Path("mediaId") mediaId: String, + @FieldMap signedForm: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/media/{mediaId}/edit_media/") + suspend fun editCaption( + @Path("mediaId") mediaId: String, + @FieldMap signedForm: Map, + ): String + + @GET("/api/v1/language/translate/") + suspend fun translate(@QueryMap form: Map): String + + @FormUrlEncoded + @POST("/api/v1/media/upload_finish/") + suspend fun uploadFinish( + @Header("retry_context") retryContext: String, + @QueryMap queryParams: Map, + @FieldMap signedForm: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/media/{mediaId}/delete/") + suspend fun delete( + @Path("mediaId") mediaId: String, + @Query("media_type") mediaType: String, + @FieldMap signedForm: Map, + ): String + + @FormUrlEncoded + @POST("/api/v1/media/{mediaId}/archive/") + suspend fun archive( + @Path("mediaId") mediaId: String, + @FieldMap signedForm: Map, + ): String +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/NewsRepository.java b/app/src/main/java/awais/instagrabber/repositories/NewsRepository.java new file mode 100644 index 0000000..ffa678b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/NewsRepository.java @@ -0,0 +1,27 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.AymlResponse; +import awais.instagrabber.repositories.responses.NewsInboxResponse; +import awais.instagrabber.repositories.responses.UserSearchResponse; +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.Query; + +public interface NewsRepository { + @GET("/api/v1/news/inbox/") + Call appInbox(@Query(value = "mark_as_seen", encoded = true) boolean markAsSeen, + @Header(value = "x-ig-app-id") String xIgAppId); + + @FormUrlEncoded + @POST("/api/v1/discover/ayml/") + Call getAyml(@FieldMap final Map form); + + @GET("/api/v1/discover/chaining/") + Call getChaining(@Query(value = "target_id") long targetId); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java b/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java new file mode 100644 index 0000000..38fdffc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java @@ -0,0 +1,40 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.WrappedFeedResponse; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; +import awais.instagrabber.repositories.responses.UserFeedResponse; +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.Path; +import retrofit2.http.POST; +import retrofit2.http.QueryMap; + +public interface ProfileRepository { + + @GET("/api/v1/feed/user/{uid}/") + Call fetch(@Path("uid") final long uid, @QueryMap Map queryParams); + + @GET("/api/v1/feed/saved/") + Call fetchSaved(@QueryMap Map queryParams); + + @GET("/api/v1/feed/collection/{collectionId}/") + Call fetchSavedCollection(@Path("collectionId") final String collectionId, + @QueryMap Map queryParams); + + @GET("/api/v1/feed/liked/") + Call fetchLiked(@QueryMap Map queryParams); + + @GET("/api/v1/usertags/{profileId}/feed/") + Call fetchTagged(@Path("profileId") final long profileId, @QueryMap Map queryParams); + + @GET("/api/v1/collections/list/") + Call fetchCollections(@QueryMap Map queryParams); + + @FormUrlEncoded + @POST("/api/v1/collections/create/") + Call createCollection(@FieldMap Map signedForm); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/SearchRepository.java b/app/src/main/java/awais/instagrabber/repositories/SearchRepository.java new file mode 100644 index 0000000..148e8f4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/SearchRepository.java @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.search.SearchResponse; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.QueryMap; +import retrofit2.http.Url; + +public interface SearchRepository { + @GET + Call search(@Url String url, @QueryMap(encoded = true) Map queryParams); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/StoriesService.kt b/app/src/main/java/awais/instagrabber/repositories/StoriesService.kt new file mode 100644 index 0000000..c8920d7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/StoriesService.kt @@ -0,0 +1,49 @@ +package awais.instagrabber.repositories + +import awais.instagrabber.repositories.responses.stories.ArchiveResponse +import awais.instagrabber.repositories.responses.stories.ReelsMediaResponse +import awais.instagrabber.repositories.responses.stories.ReelsResponse +import awais.instagrabber.repositories.responses.stories.ReelsTrayResponse +import awais.instagrabber.repositories.responses.stories.StoryMediaResponse +import awais.instagrabber.repositories.responses.stories.StoryStickerResponse +import retrofit2.http.* + +interface StoriesService { + // this one is the same as MediaRepository.fetch BUT you need to make sure it's a story + @GET("/api/v1/media/{mediaId}/info/") + suspend fun fetch(@Path("mediaId") mediaId: Long): StoryMediaResponse + + @GET("/api/v1/feed/reels_tray/") + suspend fun getFeedStories(): ReelsTrayResponse? + + @GET("/api/v1/highlights/{uid}/highlights_tray/") + suspend fun fetchHighlights(@Path("uid") uid: Long): ReelsTrayResponse? + + @GET("/api/v1/archive/reel/day_shells/") + suspend fun fetchArchive(@QueryMap queryParams: Map): ArchiveResponse? + + @GET("/api/v1/feed/reels_media/") + suspend fun getReelsMedia(@Query("user_ids") id: String): ReelsMediaResponse + + @GET("/api/v1/{type}/{id}/story/") + suspend fun getStories(@Path("type") type: String, @Path("id") id: String): ReelsResponse + + @GET("/api/v1/feed/user/{id}/story/") + suspend fun getUserStories(@Path("id") id: Long): ReelsResponse + + @FormUrlEncoded + @POST("/api/v1/media/{storyId}/{stickerId}/{action}/") + suspend fun respondToSticker( + @Path("storyId") storyId: Long, + @Path("stickerId") stickerId: Long, + @Path("action") action: String, // story_poll_vote, story_question_response, story_slider_vote, story_quiz_answer + @FieldMap form: Map, + ): StoryStickerResponse + + @FormUrlEncoded + @POST("/api/v2/media/seen/") + suspend fun seen( + @QueryMap queryParams: Map, + @FieldMap form: Map, + ): String +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/TagsRepository.java b/app/src/main/java/awais/instagrabber/repositories/TagsRepository.java new file mode 100644 index 0000000..a70ed36 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/TagsRepository.java @@ -0,0 +1,29 @@ +package awais.instagrabber.repositories; + +import java.util.Map; + +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.TagFeedResponse; +import retrofit2.Call; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.QueryMap; + +public interface TagsRepository { + @GET("/api/v1/tags/{tag}/info/") + Call fetch(@Path("tag") final String tag); + + @FormUrlEncoded + @POST("/api/v1/tags/{action}/{tag}/") + Call changeFollow(@FieldMap final Map signedForm, + @Path("action") String action, + @Path("tag") String tag); + + @GET("/api/v1/feed/tag/{tag}/") + Call fetchPosts(@Path("tag") final String tag, + @QueryMap Map queryParams); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/UserService.kt b/app/src/main/java/awais/instagrabber/repositories/UserService.kt new file mode 100644 index 0000000..67d91d5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/UserService.kt @@ -0,0 +1,25 @@ +package awais.instagrabber.repositories + +import awais.instagrabber.repositories.responses.FriendshipStatus +import awais.instagrabber.repositories.responses.UserSearchResponse +import awais.instagrabber.repositories.responses.WrappedUser +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface UserService { + @GET("/api/v1/users/{uid}/info/") + suspend fun getUserInfo(@Path("uid") uid: Long): WrappedUser + + @GET("/api/v1/users/{username}/usernameinfo/") + suspend fun getUsernameInfo(@Path("username") username: String): WrappedUser + + @GET("/api/v1/friendships/show/{uid}/") + suspend fun getUserFriendship(@Path("uid") uid: Long): FriendshipStatus + + @GET("/api/v1/users/search/") + suspend fun search( + @Query("timezone_offset") timezoneOffset: Float, + @Query("q") query: String, + ): UserSearchResponse +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/StoryViewerOptions.java b/app/src/main/java/awais/instagrabber/repositories/requests/StoryViewerOptions.java new file mode 100644 index 0000000..7a82a29 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/StoryViewerOptions.java @@ -0,0 +1,97 @@ +package awais.instagrabber.repositories.requests; + +import java.io.Serializable; + +public class StoryViewerOptions implements Serializable { + private final long id; + private final String name; + private final Type type; + private int currentFeedStoryIndex; + + private StoryViewerOptions(final int position, final Type type) { + id = 0; + name = null; + this.currentFeedStoryIndex = position; + this.type = type; + } + + private StoryViewerOptions(final String name, final Type type) { + this.name = name; + this.id = 0; + this.type = type; + } + + private StoryViewerOptions(final long id, final Type type) { + this.name = null; + this.id = id; + this.type = type; + } + + private StoryViewerOptions(final long id, final String name, final Type type) { + this.id = id; + this.name = name; + this.type = type; + } + + public static StoryViewerOptions forHashtag(final String name) { + return new StoryViewerOptions(name, Type.HASHTAG); + } + + public static StoryViewerOptions forLocation(final long id, final String name) { + return new StoryViewerOptions(id, name, Type.LOCATION); + } + + public static StoryViewerOptions forUser(final long id, final String name) { + return new StoryViewerOptions(id, name, Type.USER); + } + + public static StoryViewerOptions forHighlight(final long id, final String highlight) { + return new StoryViewerOptions(id, highlight, Type.HIGHLIGHT); + } + + public static StoryViewerOptions forStory(final long mediaId, final String username) { + return new StoryViewerOptions(mediaId, username, Type.STORY); + } + + public static StoryViewerOptions forFeedStoryPosition(final int position) { + return new StoryViewerOptions(position, Type.FEED_STORY_POSITION); + } + + public static StoryViewerOptions forStoryArchive(final String id) { + return new StoryViewerOptions(id, Type.STORY_ARCHIVE); + } + + public static StoryViewerOptions forStoryArchive(final int position) { + return new StoryViewerOptions(position, Type.STORY_ARCHIVE); + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public Type getType() { + return type; + } + + public int getCurrentFeedStoryIndex() { + return currentFeedStoryIndex; + } + + public void setCurrentFeedStoryIndex(final int index) { + this.currentFeedStoryIndex = index; + } + + public enum Type { + HASHTAG, + LOCATION, + USER, + HIGHLIGHT, + STORY, + FEED_STORY_POSITION, + STORY_ARCHIVE + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/UploadFinishOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/UploadFinishOptions.kt new file mode 100644 index 0000000..6f14a2b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/UploadFinishOptions.kt @@ -0,0 +1,27 @@ +package awais.instagrabber.repositories.requests + +data class UploadFinishOptions( + val uploadId: String, + val sourceType: String, + val videoOptions: VideoOptions? = null +) + +data class VideoOptions( + val length: Float = 0f, + var clips: List = emptyList(), + val posterFrameIndex: Int = 0, + val isAudioMuted: Boolean = false +) { + val map: Map + get() = mapOf( + "length" to length, + "clips" to clips, + "poster_frame_index" to posterFrameIndex, + "audio_muted" to isAudioMuted + ) +} + +data class Clip( + val length: Float = 0f, + val sourceType: String +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/AnimatedMediaBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/AnimatedMediaBroadcastOptions.kt new file mode 100644 index 0000000..a0f1b74 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/AnimatedMediaBroadcastOptions.kt @@ -0,0 +1,20 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType +import awais.instagrabber.repositories.responses.giphy.GiphyGif + +class AnimatedMediaBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val giphyGif: GiphyGif +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.ANIMATED_MEDIA +) { + override val formMap: Map + get() = mapOf( + "is_sticker" to giphyGif.isSticker.toString(), + "id" to giphyGif.id + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.kt new file mode 100644 index 0000000..55841b6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.kt @@ -0,0 +1,18 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +sealed class BroadcastOptions( + val clientContext: String, + private val threadIdsOrUserIds: ThreadIdsOrUserIds, + val itemType: BroadcastItemType +) { + var repliedToItemId: String? = null + var repliedToClientContext: String? = null + val threadIds: List? + get() = threadIdsOrUserIds.threadIds + val userIds: List>? + get() = threadIdsOrUserIds.userIds + + abstract val formMap: Map +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/LinkBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/LinkBroadcastOptions.kt new file mode 100644 index 0000000..64257d6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/LinkBroadcastOptions.kt @@ -0,0 +1,21 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType +import org.json.JSONArray + +class LinkBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val linkText: String, + val urls: List +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.LINK +) { + override val formMap: Map + get() = mapOf( + "link_text" to linkText, + "link_urls" to JSONArray(urls).toString() + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/MediaShareBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/MediaShareBroadcastOptions.kt new file mode 100644 index 0000000..c83ef73 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/MediaShareBroadcastOptions.kt @@ -0,0 +1,20 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class MediaShareBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val mediaId: String, + val childId: String? +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.MEDIA_SHARE +) { + override val formMap: Map + get() = listOfNotNull( + "media_id" to mediaId, + if (childId != null) "carousel_share_child_media_id" to childId else null + ).toMap() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/PhotoBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/PhotoBroadcastOptions.kt new file mode 100644 index 0000000..0a7ed21 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/PhotoBroadcastOptions.kt @@ -0,0 +1,20 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class PhotoBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val allowFullAspectRatio: Boolean, + val uploadId: String +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.IMAGE +) { + override val formMap: Map + get() = mapOf( + "allow_full_aspect_ratio" to allowFullAspectRatio.toString(), + "upload_id" to uploadId + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ProfileBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ProfileBroadcastOptions.kt new file mode 100644 index 0000000..5b4e2fc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ProfileBroadcastOptions.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class ProfileBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val profileId: String +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.PROFILE +) { + override val formMap: Map + get() = mapOf("profile_user_id" to profileId) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ReactionBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ReactionBroadcastOptions.kt new file mode 100644 index 0000000..0598e92 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ReactionBroadcastOptions.kt @@ -0,0 +1,19 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class ReactionBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val itemId: String, + val emoji: String?, + val delete: Boolean +) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.REACTION) { + override val formMap: Map + get() = listOfNotNull( + "item_id" to itemId, + "reaction_status" to if (delete) "deleted" else "created", + "reaction_type" to "like", + if (!emoji.isNullOrBlank()) "emoji" to emoji else null, + ).toMap() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryBroadcastOptions.kt new file mode 100644 index 0000000..309cb5b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryBroadcastOptions.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class StoryBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val mediaId: String, + val reelId: String +) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.STORY) { + override val formMap: Map + get() = mapOf( + "story_media_id" to mediaId, + "reel_id" to reelId, + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryReplyBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryReplyBroadcastOptions.kt new file mode 100644 index 0000000..5de0991 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryReplyBroadcastOptions.kt @@ -0,0 +1,19 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class StoryReplyBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val text: String, + val mediaId: String, + val reelId: String +) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.REELSHARE) { + override val formMap: Map + get() = mapOf( + "text" to text, + "media_id" to mediaId, + "reel_id" to reelId, + "entry" to "reel", + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/TextBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/TextBroadcastOptions.kt new file mode 100644 index 0000000..d128084 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/TextBroadcastOptions.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class TextBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val text: String +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.TEXT +) { + override val formMap: Map + get() = mapOf("text" to text) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ThreadIdsOrUserIds.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ThreadIdsOrUserIds.kt new file mode 100644 index 0000000..0a200f1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ThreadIdsOrUserIds.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.requests.directmessages + +data class ThreadIdsOrUserIds(val threadIds: List? = null, val userIds: List>? = null) { + companion object { + @JvmStatic + fun of(threadId: String): ThreadIdsOrUserIds { + return ThreadIdsOrUserIds(listOf(threadId), null) + } + + fun ofOneUser(userId: String): ThreadIdsOrUserIds { + return ThreadIdsOrUserIds(null, listOf(listOf(userId))) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VideoBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VideoBroadcastOptions.kt new file mode 100644 index 0000000..edb437a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VideoBroadcastOptions.kt @@ -0,0 +1,22 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType + +class VideoBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val videoResult: String, + val uploadId: String, + val sampled: Boolean +) : BroadcastOptions( + clientContext, + threadIdsOrUserIds, + BroadcastItemType.VIDEO +) { + override val formMap: Map + get() = mapOf( + "video_result" to videoResult, + "upload_id" to uploadId, + "sampled" to sampled.toString() + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VoiceBroadcastOptions.kt b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VoiceBroadcastOptions.kt new file mode 100644 index 0000000..8ff6911 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VoiceBroadcastOptions.kt @@ -0,0 +1,19 @@ +package awais.instagrabber.repositories.requests.directmessages + +import awais.instagrabber.models.enums.BroadcastItemType +import org.json.JSONArray + +class VoiceBroadcastOptions( + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + val uploadId: String, + val waveform: List, + val waveformSamplingFrequencyHz: Int +) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.VOICE) { + override val formMap: Map + get() = mapOf( + "waveform" to JSONArray(waveform).toString(), + "upload_id" to uploadId, + "waveform_sampling_frequency_hz" to waveformSamplingFrequencyHz.toString() + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaFixedHeight.kt b/app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaFixedHeight.kt new file mode 100644 index 0000000..4258f91 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaFixedHeight.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class AnimatedMediaFixedHeight(val height: Int, val width: Int, val mp4: String?, val url: String?, val webp: String?) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaImages.kt b/app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaImages.kt new file mode 100644 index 0000000..b9786c5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaImages.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class AnimatedMediaImages(val fixedHeight: AnimatedMediaFixedHeight?) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Audio.kt b/app/src/main/java/awais/instagrabber/repositories/responses/Audio.kt new file mode 100644 index 0000000..fbd18e5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Audio.kt @@ -0,0 +1,11 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class Audio( + val audioSrc: String?, + val duration: Long, + val waveformData: List?, + val waveformSamplingFrequencyHz: Int, + val audioSrcExpirationTimestampUs: Long +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/AymlResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/AymlResponse.kt new file mode 100644 index 0000000..3335ec7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/AymlResponse.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class AymlResponse(val newSuggestedUsers: AymlUserList?, val suggestedUsers: AymlUserList?) : Serializable + +data class AymlUser( + val user: User?, + val algorithm: String?, + val socialContext: String?, + val uuid: String? +) : Serializable + +data class AymlUserList(val suggestions: List?) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Caption.kt b/app/src/main/java/awais/instagrabber/repositories/responses/Caption.kt new file mode 100644 index 0000000..48be369 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Caption.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class Caption( + val userId: Long = 0, + var text: String? = null, +) : Serializable { + var pk: String? = null +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/ChildCommentsFetchResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/ChildCommentsFetchResponse.kt new file mode 100644 index 0000000..19a9ce9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/ChildCommentsFetchResponse.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses + +import awais.instagrabber.models.Comment + +data class ChildCommentsFetchResponse( + val childCommentCount: Int, + val nextMaxChildCursor: String?, + val childComments: List?, + val hasMoreTailChildComments: Boolean? +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/CommentsFetchResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/CommentsFetchResponse.kt new file mode 100644 index 0000000..acf21ee --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/CommentsFetchResponse.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses + +import awais.instagrabber.models.Comment + +data class CommentsFetchResponse( + val commentCount: Int, + val nextMinId: String?, + val comments: List?, + val hasMoreComments: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipChangeResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipChangeResponse.kt new file mode 100644 index 0000000..1dcdbc5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipChangeResponse.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses + +data class FriendshipChangeResponse(val friendshipStatus: FriendshipStatus?, val status: String?) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipListFetchResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipListFetchResponse.kt new file mode 100644 index 0000000..25145aa --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipListFetchResponse.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses + +data class FriendshipListFetchResponse( + var nextMaxId: String?, + var status: String?, + var users: List? +) { + val isMoreAvailable: Boolean + get() = !nextMaxId.isNullOrBlank() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipRestrictResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipRestrictResponse.kt new file mode 100644 index 0000000..9cc72b8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipRestrictResponse.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses + +data class FriendshipRestrictResponse(val users: List?, val status: String?) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipStatus.kt b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipStatus.kt new file mode 100644 index 0000000..374517d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/FriendshipStatus.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class FriendshipStatus( + val following: Boolean = false, + val followedBy: Boolean = false, + val blocking: Boolean = false, + val muting: Boolean = false, + val isPrivate: Boolean = false, + val incomingRequest: Boolean = false, + val outgoingRequest: Boolean = false, + val isBestie: Boolean = false, + val isRestricted: Boolean = false, + val isMutingReel: Boolean = false, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/GraphQLUserListFetchResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/GraphQLUserListFetchResponse.java new file mode 100644 index 0000000..5a0587a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/GraphQLUserListFetchResponse.java @@ -0,0 +1,78 @@ +package awais.instagrabber.repositories.responses; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.utils.TextUtils; + +public class GraphQLUserListFetchResponse { + private String nextMaxId; + private String status; + private List items; + + public GraphQLUserListFetchResponse(final String nextMaxId, + final String status, + final List items) { + this.nextMaxId = nextMaxId; + this.status = status; + this.items = items; + } + + public boolean isMoreAvailable() { + return !TextUtils.isEmpty(nextMaxId); + } + + public String getNextMaxId() { + return nextMaxId; + } + + public GraphQLUserListFetchResponse setNextMaxId(final String nextMaxId) { + this.nextMaxId = nextMaxId; + return this; + } + + public String getStatus() { + return status; + } + + public GraphQLUserListFetchResponse setStatus(final String status) { + this.status = status; + return this; + } + + public List getItems() { + return items; + } + + public GraphQLUserListFetchResponse setItems(final List items) { + this.items = items; + return this; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GraphQLUserListFetchResponse that = (GraphQLUserListFetchResponse) o; + return Objects.equals(nextMaxId, that.nextMaxId) && + Objects.equals(status, that.status) && + Objects.equals(items, that.items); + } + + @Override + public int hashCode() { + return Objects.hash(nextMaxId, status, items); + } + + @NonNull + @Override + public String toString() { + return "GraphQLUserListFetchResponse{" + + "nextMaxId='" + nextMaxId + '\'' + + ", status='" + status + '\'' + + ", items=" + items + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.kt b/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.kt new file mode 100755 index 0000000..9d8fef0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.repositories.responses + +import awais.instagrabber.models.enums.FollowingType +import java.io.Serializable + +data class Hashtag( + val id: String, + val name: String, + val mediaCount: Long, + val following: FollowingType?, // 0 false 1 true; not on search results + val searchResultSubtitle: String? // shows how many posts there are on search results +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/ImageUrl.kt b/app/src/main/java/awais/instagrabber/repositories/responses/ImageUrl.kt new file mode 100644 index 0000000..0c2362d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/ImageUrl.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class ImageUrl(val url: String, private val width: Int, private val height: Int) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/ImageVersions2.kt b/app/src/main/java/awais/instagrabber/repositories/responses/ImageVersions2.kt new file mode 100644 index 0000000..6bed8a1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/ImageVersions2.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class ImageVersions2(val candidates: List) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/LikersResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/LikersResponse.kt new file mode 100644 index 0000000..8ea2aeb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/LikersResponse.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses + +data class LikersResponse(val users: List, val userCount: Long, val status: String) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Location.java b/app/src/main/java/awais/instagrabber/repositories/responses/Location.java new file mode 100644 index 0000000..c6a3d95 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Location.java @@ -0,0 +1,79 @@ +package awais.instagrabber.repositories.responses; + +import java.io.Serializable; +import java.util.Objects; + +public class Location implements Serializable { + private final long pk; + private final String shortName; + private final String name; + private final String address; + private final String city; + private final double lng; + private final double lat; + + public Location(final long pk, + final String shortName, + final String name, + final String address, + final String city, + final double lng, + final double lat) { + this.pk = pk; + this.shortName = shortName; + this.name = name; + this.address = address; + this.city = city; + this.lng = lng; + this.lat = lat; + } + + public long getPk() { + return pk; + } + + public String getShortName() { + return shortName; + } + + public String getName() { + return name; + } + + public String getAddress() { + return address; + } + + public String getCity() { + return city; + } + + public double getLng() { + return lng; + } + + public double getLat() { + return lat; + } + + public String getGeo() { return "geo:" + lat + "," + lng + "?z=17&q=" + lat + "," + lng + "(" + name + ")"; } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Location location = (Location) o; + return pk == location.pk && + Double.compare(location.lng, lng) == 0 && + Double.compare(location.lat, lat) == 0 && + Objects.equals(shortName, location.shortName) && + Objects.equals(name, location.name) && + Objects.equals(address, location.address) && + Objects.equals(city, location.city); + } + + @Override + public int hashCode() { + return Objects.hash(pk, shortName, name, address, city, lng, lat); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/LocationFeedResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/LocationFeedResponse.kt new file mode 100644 index 0000000..e1e9ce1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/LocationFeedResponse.kt @@ -0,0 +1,11 @@ +package awais.instagrabber.repositories.responses + +data class LocationFeedResponse( + val numResults: Int, + val nextMaxId: String?, + val moreAvailable: Boolean?, + val mediaCount: Long?, + val status: String, + val items: List?, + val location: Location +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Media.kt b/app/src/main/java/awais/instagrabber/repositories/responses/Media.kt new file mode 100644 index 0000000..fa1220e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Media.kt @@ -0,0 +1,75 @@ +package awais.instagrabber.repositories.responses + +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.models.enums.MediaItemType.Companion.valueOf +import awais.instagrabber.repositories.responses.feed.EndOfFeedDemarcator +import awais.instagrabber.utils.TextUtils +import java.io.Serializable + +data class Media( + val pk: String? = null, + val id: String? = null, + val code: String? = null, + val takenAt: Long = -1, + val user: User? = null, + val canViewerReshare: Boolean = false, + val imageVersions2: ImageVersions2? = null, + val originalWidth: Int = 0, + val originalHeight: Int = 0, + val mediaType: Int = 0, + val commentLikesEnabled: Boolean = false, + val commentsDisabled: Boolean = false, + val nextMaxId: Long = -1, + val commentCount: Long = 0, + var likeCount: Long = 0, + var hasLiked: Boolean = false, + val isReelMedia: Boolean = false, + val videoVersions: List? = null, + val hasAudio: Boolean = false, + val videoDuration: Double = 0.0, + val viewCount: Long = 0, + var caption: Caption? = null, + val canViewerSave: Boolean = false, + val audio: Audio? = null, + val title: String? = null, + val carouselMedia: List? = null, + val location: Location? = null, + val usertags: Usertags? = null, + var isSidecarChild: Boolean = false, + var hasViewerSaved: Boolean = false, + private val injected: Map? = null, + val endOfFeedDemarcator: EndOfFeedDemarcator? = null, + val carouselShareChildMediaId: String? = null // which specific child should dm show first +) : Serializable { + private var dateString: String? = null + + fun isInjected(): Boolean { + return injected != null + } + + // TODO use extension once all usages are converted to kotlin + // val date: String by lazy { + // if (takenAt <= 0) "" else Utils.datetimeParser.format(Date(takenAt * 1000L)) + // } + val date: String + get() { + if (takenAt <= 0) return "" + if (dateString != null) return dateString ?: "" + dateString = TextUtils.epochSecondToString(takenAt) + return dateString ?: "" + } + + val type: MediaItemType? + get() = valueOf(mediaType) + + fun setPostCaption(caption: String?) { + var caption1: Caption? = this.caption + if (caption1 == null) { + user ?: return + caption1 = Caption(userId = user.pk, text = caption ?: "") + this.caption = caption1 + return + } + caption1.text = caption ?: "" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/MediaCandidate.kt b/app/src/main/java/awais/instagrabber/repositories/responses/MediaCandidate.kt new file mode 100644 index 0000000..5de5ea8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/MediaCandidate.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + +data class MediaCandidate(val width: Int, val height: Int, val url: String) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/MediaInfoResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/MediaInfoResponse.kt new file mode 100644 index 0000000..139d3c4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/MediaInfoResponse.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses + +data class MediaInfoResponse(val items: List) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/NewsInboxResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/NewsInboxResponse.kt new file mode 100644 index 0000000..74c1417 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/NewsInboxResponse.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses + +import awais.instagrabber.repositories.responses.notification.Notification +import awais.instagrabber.repositories.responses.notification.NotificationCounts + +data class NewsInboxResponse( + val counts: NotificationCounts, + val newStories: List, + val oldStories: List +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Place.kt b/app/src/main/java/awais/instagrabber/repositories/responses/Place.kt new file mode 100644 index 0000000..a0004a5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Place.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.repositories.responses + +data class Place( + val location: Location, + // for search + val title: String, // those are repeated within location + val subtitle: String?, // address + // browser only; for end of address + val slug: String?, + // for location info + val status: String? +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/PostsFetchResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/PostsFetchResponse.kt new file mode 100644 index 0000000..64082fc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/PostsFetchResponse.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.repositories.responses + +class PostsFetchResponse( + val feedModels: List, + val hasNextPage: Boolean, + val nextCursor: String? +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/TagFeedResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/TagFeedResponse.kt new file mode 100644 index 0000000..d9bfb37 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/TagFeedResponse.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses + +class TagFeedResponse( + val numResults: Int, + val nextMaxId: String?, + val moreAvailable: Boolean, + val status: String, + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/User.kt b/app/src/main/java/awais/instagrabber/repositories/responses/User.kt new file mode 100644 index 0000000..6f465fb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/User.kt @@ -0,0 +1,38 @@ +package awais.instagrabber.repositories.responses + +import java.io.Serializable + + +data class User @JvmOverloads constructor( + val pk: Long = 0, + val username: String = "", + val fullName: String? = "", + val isPrivate: Boolean = false, + val profilePicUrl: String? = null, + val isVerified: Boolean = false, + val profilePicId: String? = null, + var friendshipStatus: FriendshipStatus? = null, + val hasAnonymousProfilePicture: Boolean = false, + val isUnpublished: Boolean = false, + val isFavorite: Boolean = false, + val isDirectappInstalled: Boolean = false, + val hasChaining: Boolean = false, + val reelAutoArchive: String? = null, + val allowedCommenterType: String? = null, + val mediaCount: Long = 0, + val followerCount: Long = 0, + val followingCount: Long = 0, + val followingTagCount: Long = 0, + val biography: String? = null, + val externalUrl: String? = null, + val usertagsCount: Long = 0, + val publicEmail: String? = null, + val hdProfilePicUrlInfo: ImageUrl? = null, + val profileContext: String? = null, // "also followed by" your friends + val profileContextLinksWithUserIds: List? = null, // ^ + val socialContext: String? = null, // AYML + val interopMessagingUserFbid: String? = null, // in DMs only: Facebook user ID +) : Serializable { + val hDProfilePicUrl: String + get() = hdProfilePicUrlInfo?.url ?: profilePicUrl ?: "" +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UserFeedResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/UserFeedResponse.kt new file mode 100644 index 0000000..9a68fa4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/UserFeedResponse.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses + +class UserFeedResponse( + val numResults: Int, + val nextMaxId: String?, + val moreAvailable: Boolean, + val status: String, + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt b/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt new file mode 100644 index 0000000..8b9977a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.repositories.responses + +data class UserProfileContextLink( + val username: String? = null, + val start: Int = 0, + val end: Int = 0, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.kt new file mode 100644 index 0000000..32c6edb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.repositories.responses + +data class UserSearchResponse( + val numResults: Int, + val users: List?, + val status: String +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UsertagIn.java b/app/src/main/java/awais/instagrabber/repositories/responses/UsertagIn.java new file mode 100644 index 0000000..9a5456e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/UsertagIn.java @@ -0,0 +1,37 @@ +package awais.instagrabber.repositories.responses; + +import java.io.Serializable; +import java.util.List; +import java.util.Objects; + +public class UsertagIn implements Serializable { + private final User user; + private final List position; + + public UsertagIn(final User user, final List position) { + this.user = user; + this.position = position; + } + + public User getUser() { + return user; + } + + public List getPosition() { + return position; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final UsertagIn usertagIn = (UsertagIn) o; + return Objects.equals(user, usertagIn.user) && + Objects.equals(position, usertagIn.position); + } + + @Override + public int hashCode() { + return Objects.hash(user, position); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Usertags.java b/app/src/main/java/awais/instagrabber/repositories/responses/Usertags.java new file mode 100644 index 0000000..08bf7b1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Usertags.java @@ -0,0 +1,30 @@ +package awais.instagrabber.repositories.responses; + +import java.io.Serializable; +import java.util.List; +import java.util.Objects; + +public class Usertags implements Serializable { + private final List in; + + public Usertags(final List in) { + this.in = in; + } + + public List getIn() { + return in; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Usertags usertags = (Usertags) o; + return Objects.equals(in, usertags.in); + } + + @Override + public int hashCode() { + return Objects.hash(in); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/WrappedFeedResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/WrappedFeedResponse.java new file mode 100644 index 0000000..e40b584 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/WrappedFeedResponse.java @@ -0,0 +1,43 @@ +package awais.instagrabber.repositories.responses; + +import java.util.List; + +public class WrappedFeedResponse { + private final int numResults; + private final String nextMaxId; + private final boolean moreAvailable; + private final String status; + private final List items; + + public WrappedFeedResponse(final int numResults, + final String nextMaxId, + final boolean moreAvailable, + final String status, + final List items) { + this.numResults = numResults; + this.nextMaxId = nextMaxId; + this.moreAvailable = moreAvailable; + this.status = status; + this.items = items; + } + + public int getNumResults() { + return numResults; + } + + public String getNextMaxId() { + return nextMaxId; + } + + public boolean isMoreAvailable() { + return moreAvailable; + } + + public String getStatus() { + return status; + } + + public List getItems() { + return items; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/WrappedMedia.kt b/app/src/main/java/awais/instagrabber/repositories/responses/WrappedMedia.kt new file mode 100644 index 0000000..e09a20b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/WrappedMedia.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses + +class WrappedMedia(val media: Media) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/WrappedUser.kt b/app/src/main/java/awais/instagrabber/repositories/responses/WrappedUser.kt new file mode 100644 index 0000000..62c3ef0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/WrappedUser.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses + +class WrappedUser(val user: User) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectBadgeCount.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectBadgeCount.kt new file mode 100644 index 0000000..e04966a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectBadgeCount.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectBadgeCount( + val userId: Long = 0, + val badgeCount: Int = 0, + val badgeCountAtMs: Long = 0, + val status: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInbox.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInbox.kt new file mode 100644 index 0000000..2de859b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInbox.kt @@ -0,0 +1,15 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectInbox( + var threads: List? = emptyList(), + val hasOlder: Boolean = false, + val unseenCount: Int = 0, + val unseenCountTs: String? = null, + val oldestCursor: String? = null, + val blendedInboxEnabled: Boolean +) : Cloneable { + @Throws(CloneNotSupportedException::class) + public override fun clone(): Any { + return super.clone() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.kt new file mode 100644 index 0000000..3b91af8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.User + +data class DirectInboxResponse( + val viewer: User? = null, + val inbox: DirectInbox? = null, + val seqId: Long = 0, + val snapshotAtMs: Long = 0, + val pendingRequestsTotal: Int = 0, + val hasPendingTopRequests: Boolean = false, + val mostRecentInviter: User? = null, + val status: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.kt new file mode 100644 index 0000000..daa759c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.kt @@ -0,0 +1,65 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.models.enums.DirectItemType +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.User +import java.io.Serializable +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +data class DirectItem( + var itemId: String? = null, + val userId: Long = 0, + private var timestamp: Long = 0, + val itemType: DirectItemType? = null, + val text: String? = null, + val like: String? = null, + val link: DirectItemLink? = null, + val clientContext: String? = null, + val reelShare: DirectItemReelShare? = null, + val storyShare: DirectItemStoryShare? = null, + val mediaShare: Media? = null, + val profile: User? = null, + val placeholder: DirectItemPlaceholder? = null, + val media: Media? = null, + val previewMedias: List? = null, + val actionLog: DirectItemActionLog? = null, + val videoCallEvent: DirectItemVideoCallEvent? = null, + val clip: DirectItemClip? = null, + val felixShare: DirectItemFelixShare? = null, + val visualMedia: DirectItemVisualMedia? = null, + val animatedMedia: DirectItemAnimatedMedia? = null, + var reactions: DirectItemReactions? = null, + val repliedToMessage: DirectItem? = null, + val voiceMedia: DirectItemVoiceMedia? = null, + val location: Location? = null, + val xma: DirectItemXma? = null, + val hideInThread: Int? = 0, + val showForwardAttribution: Boolean = false +) : Cloneable, Serializable { + var isPending = false + var date: LocalDateTime? = null + get() { + if (field == null) { + field = Instant.ofEpochMilli(timestamp / 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + } + return field + } + private set + + fun getTimestamp(): Long { + return timestamp + } + + fun setTimestamp(timestamp: Long) { + this.timestamp = timestamp + date = null + } + + @Throws(CloneNotSupportedException::class) + public override fun clone(): Any { + return super.clone() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemActionLog.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemActionLog.kt new file mode 100644 index 0000000..320e409 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemActionLog.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemActionLog( + val description: String? = null, + val bold: List? = null, + val textAttributes: List? = null +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemAnimatedMedia.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemAnimatedMedia.kt new file mode 100644 index 0000000..085943e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemAnimatedMedia.kt @@ -0,0 +1,11 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.AnimatedMediaImages +import java.io.Serializable + +data class DirectItemAnimatedMedia( + val id: String? = null, + val images: AnimatedMediaImages? = null, + val isRandom: Boolean = false, + val isSticker: Boolean = false, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemClip.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemClip.kt new file mode 100644 index 0000000..a141969 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemClip.kt @@ -0,0 +1,6 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class DirectItemClip(val clip: Media? = null) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemEmojiReaction.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemEmojiReaction.kt new file mode 100644 index 0000000..a062040 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemEmojiReaction.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemEmojiReaction( + val senderId: Long = 0, + val timestamp: Long = 0, + val emoji: String? = null, + val superReactType: String? = null +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemFelixShare.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemFelixShare.kt new file mode 100644 index 0000000..83029a1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemFelixShare.kt @@ -0,0 +1,6 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class DirectItemFelixShare(val video: Media? = null) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLink.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLink.kt new file mode 100644 index 0000000..f190568 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLink.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemLink( + val text: String? = null, + val linkContext: DirectItemLinkContext? = null, + val clientContext: String? = null, + val mutationToken: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLinkContext.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLinkContext.kt new file mode 100644 index 0000000..5190208 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLinkContext.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemLinkContext( + val linkUrl: String? = null, + val linkTitle: String? = null, + val linkSummary: String? = null, + val linkImageUrl: String? = null +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemPlaceholder.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemPlaceholder.kt new file mode 100644 index 0000000..d01006e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemPlaceholder.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemPlaceholder( + val isLinked: Boolean = false, + val title: String? = null, + val message: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReactions.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReactions.kt new file mode 100644 index 0000000..5f08c49 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReactions.kt @@ -0,0 +1,13 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemReactions( + var emojis: List? = null, + var likes: List? = null, +) : Cloneable, Serializable { + @Throws(CloneNotSupportedException::class) + public override fun clone(): Any { + return super.clone() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShare.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShare.kt new file mode 100644 index 0000000..4bba98b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShare.kt @@ -0,0 +1,15 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class DirectItemReelShare( + val text: String? = null, + val type: String? = null, + val reelOwnerId: Long = 0, + val mentionedUserId: Long = 0, + val isReelPersisted: Boolean = false, + val reelType: String? = null, + val media: Media? = null, + val reactionInfo: DirectItemReelShareReactionInfo? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShareReactionInfo.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShareReactionInfo.kt new file mode 100644 index 0000000..8d0aed9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShareReactionInfo.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemReelShareReactionInfo( + val emoji: String? = null, + val intensity: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponse.kt new file mode 100644 index 0000000..85d2738 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponse.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectItemSeenResponse( + val action: String? = null, + val payload: DirectItemSeenResponsePayload? = null, + val status: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponsePayload.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponsePayload.kt new file mode 100644 index 0000000..9bbdb55 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponsePayload.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectItemSeenResponsePayload(val count: Int = 0, val timestamp: String? = null) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemStoryShare.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemStoryShare.kt new file mode 100644 index 0000000..c5e669b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemStoryShare.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class DirectItemStoryShare( + val reelId: String? = null, + val reelType: String? = null, + val text: String? = null, + val isReelPersisted: Boolean = false, + val media: Media? = null, + val title: String? = null, + val message: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVideoCallEvent.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVideoCallEvent.kt new file mode 100644 index 0000000..ae1ea0a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVideoCallEvent.kt @@ -0,0 +1,11 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemVideoCallEvent( + val action: String? = null, + val encodedServerDataInfo: String? = null, + val description: String? = null, + val threadHasAudioOnlyCall: Boolean = false, + val textAttributes: List? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVisualMedia.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVisualMedia.kt new file mode 100644 index 0000000..5534e1b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVisualMedia.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.models.enums.RavenMediaViewMode +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class DirectItemVisualMedia( + val urlExpireAtSecs: Long = 0, + val playbackDurationSecs: Int = 0, + val seenUserIds: List? = null, + val viewMode: RavenMediaViewMode? = null, + val seenCount: Int = 0, + val replayExpiringAtUs: Long = 0, + val expiringMediaActionSummary: RavenExpiringMediaActionSummary? = null, + val media: Media? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVoiceMedia.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVoiceMedia.kt new file mode 100644 index 0000000..4ab35e2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVoiceMedia.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class DirectItemVoiceMedia( + val media: Media? = null, + val seenCount: Int = 0, + val viewMode: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemXma.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemXma.kt new file mode 100644 index 0000000..e28bbb9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemXma.kt @@ -0,0 +1,15 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectItemXma( + val previewUrlInfo: XmaUrlInfo? = null, + val playableUrlInfo: XmaUrlInfo? = null, +) : Serializable + +data class XmaUrlInfo( + val url: String? = null, + val urlExpirationTimestampUs: Long = 0, + val width: Int = 0, + val height: Int = 0, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.kt new file mode 100644 index 0000000..65094c7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.kt @@ -0,0 +1,49 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.User +import java.io.Serializable + +data class DirectThread( + val threadId: String? = null, + val threadV2Id: String? = null, + var users: List? = null, + var leftUsers: List? = null, + var adminUserIds: List? = null, + var items: List? = null, + val lastActivityAt: Long = 0, + var muted: Boolean = false, + val isPin: Boolean = false, + val named: Boolean = false, + val canonical: Boolean = false, + var pending: Boolean = false, + val archived: Boolean = false, + val valuedRequest: Boolean = false, + val threadType: String? = null, + val viewerId: Long = 0, + val threadTitle: String? = null, + val pendingScore: String? = null, + val folder: Long = 0, + val vcMuted: Boolean = false, + val isGroup: Boolean = false, + var mentionsMuted: Boolean = false, + val inviter: User? = null, + val hasOlder: Boolean = false, + val hasNewer: Boolean = false, + var lastSeenAt: Map? = null, + val newestCursor: String? = null, + val oldestCursor: String? = null, + val isSpam: Boolean = false, + val lastPermanentItem: DirectItem? = null, + val directStory: DirectThreadDirectStory? = null, + var approvalRequiredForNewMembers: Boolean = false, + var inputMode: Int = 0, + val threadContextItems: List? = null +) : Serializable, Cloneable { + var isTemp = false + + val firstDirectItem: DirectItem? + get() = items?.firstNotNullOfOrNull { it } + + @Throws(CloneNotSupportedException::class) + public override fun clone(): Any = super.clone() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponse.kt new file mode 100644 index 0000000..0669450 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponse.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectThreadBroadcastResponse( + val action: String? = null, + val statusCode: String? = null, + val payload: DirectThreadBroadcastResponsePayload? = null, + val messageMetadata: List? = null, + val status: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponseMessageMetadata.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponseMessageMetadata.kt new file mode 100644 index 0000000..720ab2d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponseMessageMetadata.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectThreadBroadcastResponseMessageMetadata( + val clientContext: String? = null, + val itemId: String? = null, + val timestamp: Long = 0, + val threadId: String? = null, + val participantIds: List? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponsePayload.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponsePayload.kt new file mode 100644 index 0000000..ca4655f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponsePayload.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectThreadBroadcastResponsePayload( + val clientContext: String? = null, + val itemId: String? = null, + val timestamp: Long = 0, + val threadId: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.kt new file mode 100644 index 0000000..9bc25fd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.kt @@ -0,0 +1,6 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectThreadDetailsChangeResponse( + val thread: DirectThread? = null, + val status: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDirectStory.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDirectStory.kt new file mode 100644 index 0000000..47d0bd0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDirectStory.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectThreadDirectStory( + val items: List? = null, + val unseenCount: Int = 0, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadFeedResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadFeedResponse.kt new file mode 100644 index 0000000..19f7451 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadFeedResponse.kt @@ -0,0 +1,6 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class DirectThreadFeedResponse( + val thread: DirectThread? = null, + val status: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadLastSeenAt.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadLastSeenAt.kt new file mode 100644 index 0000000..14547b4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadLastSeenAt.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class DirectThreadLastSeenAt( + val timestamp: String? = null, + val itemId: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadParticipantRequestsResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadParticipantRequestsResponse.kt new file mode 100644 index 0000000..0afac9d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadParticipantRequestsResponse.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.User +import java.io.Serializable + +data class DirectThreadParticipantRequestsResponse( + var users: List? = null, + val requesterUsernames: Map? = null, + val cursor: String? = null, + val totalThreadParticipants: Int = 0, + var totalParticipantRequests: Int = 0, + val status: String? = null, +) : Serializable, Cloneable { + @Throws(CloneNotSupportedException::class) + public override fun clone(): Any = super.clone() +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.kt new file mode 100644 index 0000000..a01ba31 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.kt @@ -0,0 +1,21 @@ +package awais.instagrabber.repositories.responses.directmessages + +import awais.instagrabber.repositories.responses.User +import java.io.Serializable + +data class RankedRecipient( + val user: User? = null, + val thread: DirectThread? = null, +) : Serializable { + companion object { + @JvmStatic + fun of(user: User): RankedRecipient { + return RankedRecipient(user = user) + } + + @JvmStatic + fun of(thread: DirectThread): RankedRecipient { + return RankedRecipient(thread = thread) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.kt new file mode 100644 index 0000000..c65d8bc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.directmessages + +data class RankedRecipientsResponse( + val rankedRecipients: List? = null, + val expires: Long = 0, + val filtered: Boolean = false, + val requestId: String? = null, + val rankToken: String? = null, + val status: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RavenExpiringMediaActionSummary.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RavenExpiringMediaActionSummary.kt new file mode 100644 index 0000000..c5c332d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RavenExpiringMediaActionSummary.kt @@ -0,0 +1,43 @@ +package awais.instagrabber.repositories.responses.directmessages + +import com.google.gson.annotations.SerializedName +import java.io.Serializable + +data class RavenExpiringMediaActionSummary( + val timestamp: Long = 0, + val count: Int = 0, + val type: ActionType? = null, +) : Serializable + +// thanks to http://github.com/warifp/InstagramAutoPostImageUrl/blob/master/vendor/mgp25/instagram-php/src/Response/Model/ActionBadge.php +enum class ActionType { + @SerializedName("raven_delivered") + DELIVERED, + + @SerializedName("raven_sent") + SENT, + + @SerializedName("raven_opened") + OPENED, + + @SerializedName("raven_screenshot") + SCREENSHOT, + + @SerializedName("raven_replayed") + REPLAYED, + + @SerializedName("raven_cannot_deliver") + CANNOT_DELIVER, + + @SerializedName("raven_sending") + SENDING, + + @SerializedName("raven_blocked") + BLOCKED, + + @SerializedName("raven_unknown") + UNKNOWN, + + @SerializedName("raven_suggested") + SUGGESTED, +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/TextRange.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/TextRange.kt new file mode 100644 index 0000000..2cea10b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/TextRange.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class TextRange( + val start: Int = 0, + val end: Int = 0, + val color: String? = null, + val intent: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/ThreadContext.kt b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/ThreadContext.kt new file mode 100644 index 0000000..6036914 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/ThreadContext.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.directmessages + +import java.io.Serializable + +data class ThreadContext( + val type: Int = 0, + val text: String? = null, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicCluster.kt b/app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicCluster.kt new file mode 100644 index 0000000..fa7963e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicCluster.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.responses.discover + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +data class TopicCluster( + val id: String, + val title: String, + val type: String?, + val canMute: Boolean?, + val isMuted: Boolean?, + val rankedPosition: Int, + var coverMedia: Media? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicalExploreFeedResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicalExploreFeedResponse.kt new file mode 100644 index 0000000..1c2e84f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicalExploreFeedResponse.kt @@ -0,0 +1,13 @@ +package awais.instagrabber.repositories.responses.discover + +import awais.instagrabber.repositories.responses.WrappedMedia + +data class TopicalExploreFeedResponse( + val moreAvailable: Boolean, + val nextMaxId: String?, + val maxId: String?, + val status: String, + val numResults: Int, + val clusters: List?, + val items: List? +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedDemarcator.java b/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedDemarcator.java new file mode 100644 index 0000000..2408bc8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedDemarcator.java @@ -0,0 +1,36 @@ +package awais.instagrabber.repositories.responses.feed; + +import java.io.Serializable; +import java.util.Objects; + +public class EndOfFeedDemarcator implements Serializable { + private final long id; + private final EndOfFeedGroupSet groupSet; + + public EndOfFeedDemarcator(final long id, final EndOfFeedGroupSet groupSet) { + this.id = id; + this.groupSet = groupSet; + } + + public long getId() { + return id; + } + + public EndOfFeedGroupSet getGroupSet() { + return groupSet; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final EndOfFeedDemarcator that = (EndOfFeedDemarcator) o; + return id == that.id && + Objects.equals(groupSet, that.groupSet); + } + + @Override + public int hashCode() { + return Objects.hash(id, groupSet); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroup.java b/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroup.java new file mode 100644 index 0000000..e675691 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroup.java @@ -0,0 +1,53 @@ +package awais.instagrabber.repositories.responses.feed; + +import java.io.Serializable; +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.repositories.responses.Media; + +public class EndOfFeedGroup implements Serializable { + private final String id; + private final String title; + private final String nextMaxId; + private final List feedItems; + + public EndOfFeedGroup(final String id, final String title, final String nextMaxId, final List feedItems) { + this.id = id; + this.title = title; + this.nextMaxId = nextMaxId; + this.feedItems = feedItems; + } + + public String getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getNextMaxId() { + return nextMaxId; + } + + public List getFeedItems() { + return feedItems; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final EndOfFeedGroup that = (EndOfFeedGroup) o; + return Objects.equals(id, that.id) && + Objects.equals(title, that.title) && + Objects.equals(nextMaxId, that.nextMaxId) && + Objects.equals(feedItems, that.feedItems); + } + + @Override + public int hashCode() { + return Objects.hash(id, title, nextMaxId, feedItems); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroupSet.java b/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroupSet.java new file mode 100644 index 0000000..cfda0f3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroupSet.java @@ -0,0 +1,70 @@ +package awais.instagrabber.repositories.responses.feed; + +import java.io.Serializable; +import java.util.List; +import java.util.Objects; + +public class EndOfFeedGroupSet implements Serializable { + private final long id; + private final String activeGroupId; + private final String connectedGroupId; + private final String nextMaxId; + private final String paginationSource; + private final List groups; + + public EndOfFeedGroupSet(final long id, + final String activeGroupId, + final String connectedGroupId, + final String nextMaxId, + final String paginationSource, + final List groups) { + this.id = id; + this.activeGroupId = activeGroupId; + this.connectedGroupId = connectedGroupId; + this.nextMaxId = nextMaxId; + this.paginationSource = paginationSource; + this.groups = groups; + } + + public long getId() { + return id; + } + + public String getActiveGroupId() { + return activeGroupId; + } + + public String getConnectedGroupId() { + return connectedGroupId; + } + + public String getNextMaxId() { + return nextMaxId; + } + + public String getPaginationSource() { + return paginationSource; + } + + public List getGroups() { + return groups; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final EndOfFeedGroupSet that = (EndOfFeedGroupSet) o; + return id == that.id && + Objects.equals(activeGroupId, that.activeGroupId) && + Objects.equals(connectedGroupId, that.connectedGroupId) && + Objects.equals(nextMaxId, that.nextMaxId) && + Objects.equals(paginationSource, that.paginationSource) && + Objects.equals(groups, that.groups); + } + + @Override + public int hashCode() { + return Objects.hash(id, activeGroupId, connectedGroupId, nextMaxId, paginationSource, groups); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/feed/FeedFetchResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/feed/FeedFetchResponse.java new file mode 100644 index 0000000..9d8659a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/feed/FeedFetchResponse.java @@ -0,0 +1,45 @@ +package awais.instagrabber.repositories.responses.feed; + +import java.util.List; + +import awais.instagrabber.repositories.responses.Media; + +public class FeedFetchResponse { + private final List items; + private final int numResults; + private final boolean moreAvailable; + private final String nextMaxId; + private final String status; + + public FeedFetchResponse(final List items, + final int numResults, + final boolean moreAvailable, + final String nextMaxId, + final String status) { + this.items = items; + this.numResults = numResults; + this.moreAvailable = moreAvailable; + this.nextMaxId = nextMaxId; + this.status = status; + } + + public List getItems() { + return items; + } + + public int getNumResults() { + return numResults; + } + + public boolean isMoreAvailable() { + return moreAvailable; + } + + public String getNextMaxId() { + return nextMaxId; + } + + public String getStatus() { + return status; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGif.java b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGif.java new file mode 100644 index 0000000..53332f3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGif.java @@ -0,0 +1,70 @@ +package awais.instagrabber.repositories.responses.giphy; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +public class GiphyGif { + private final String type; + private final String id; + private final String title; + private final int isSticker; + private final GiphyGifImages images; + + public GiphyGif(final String type, final String id, final String title, final int isSticker, final GiphyGifImages images) { + this.type = type; + this.id = id; + this.title = title; + this.isSticker = isSticker; + this.images = images; + } + + public String getType() { + return type; + } + + public String getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isSticker() { + return isSticker == 1; + } + + public GiphyGifImages getImages() { + return images; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GiphyGif giphyGif = (GiphyGif) o; + return isSticker == giphyGif.isSticker && + Objects.equals(type, giphyGif.type) && + Objects.equals(id, giphyGif.id) && + Objects.equals(title, giphyGif.title) && + Objects.equals(images, giphyGif.images); + } + + @Override + public int hashCode() { + return Objects.hash(type, id, title, isSticker, images); + } + + @NonNull + @Override + public String toString() { + return "GiphyGif{" + + "type='" + type + '\'' + + ", id='" + id + '\'' + + ", title='" + title + '\'' + + ", isSticker=" + isSticker() + + ", images=" + images + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImage.java b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImage.java new file mode 100644 index 0000000..d3659fe --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImage.java @@ -0,0 +1,59 @@ +package awais.instagrabber.repositories.responses.giphy; + +import java.util.Objects; + +public class GiphyGifImage { + private final int height; + private final int width; + private final long webpSize; + private final String webp; + + public GiphyGifImage(final int height, final int width, final long webpSize, final String webp) { + this.height = height; + this.width = width; + this.webpSize = webpSize; + this.webp = webp; + } + + public int getHeight() { + return height; + } + + public int getWidth() { + return width; + } + + public long getWebpSize() { + return webpSize; + } + + public String getWebp() { + return webp; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GiphyGifImage that = (GiphyGifImage) o; + return height == that.height && + width == that.width && + webpSize == that.webpSize && + Objects.equals(webp, that.webp); + } + + @Override + public int hashCode() { + return Objects.hash(height, width, webpSize, webp); + } + + @Override + public String toString() { + return "GiphyGifImage{" + + "height=" + height + + ", width=" + width + + ", webpSize=" + webpSize + + ", webp='" + webp + '\'' + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImages.java b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImages.java new file mode 100644 index 0000000..230b17a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImages.java @@ -0,0 +1,40 @@ +package awais.instagrabber.repositories.responses.giphy; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +import awais.instagrabber.repositories.responses.AnimatedMediaFixedHeight; + +public class GiphyGifImages { + private final AnimatedMediaFixedHeight fixedHeight; + + public GiphyGifImages(final AnimatedMediaFixedHeight fixedHeight) { + this.fixedHeight = fixedHeight; + } + + public AnimatedMediaFixedHeight getFixedHeight() { + return fixedHeight; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GiphyGifImages that = (GiphyGifImages) o; + return Objects.equals(fixedHeight, that.fixedHeight); + } + + @Override + public int hashCode() { + return Objects.hash(fixedHeight); + } + + @NonNull + @Override + public String toString() { + return "GiphyGifImages{" + + "fixedHeight=" + fixedHeight + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResponse.java new file mode 100644 index 0000000..d9fa5d0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResponse.java @@ -0,0 +1,46 @@ +package awais.instagrabber.repositories.responses.giphy; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +public class GiphyGifResponse { + private final GiphyGifResults results; + private final String status; + + public GiphyGifResponse(final GiphyGifResults results, final String status) { + this.results = results; + this.status = status; + } + + public GiphyGifResults getResults() { + return results; + } + + public String getStatus() { + return status; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GiphyGifResponse that = (GiphyGifResponse) o; + return Objects.equals(results, that.results) && + Objects.equals(status, that.status); + } + + @Override + public int hashCode() { + return Objects.hash(results, status); + } + + @NonNull + @Override + public String toString() { + return "GiphyGifResponse{" + + "results=" + results + + ", status='" + status + '\'' + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResults.java b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResults.java new file mode 100644 index 0000000..3f6fd94 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResults.java @@ -0,0 +1,47 @@ +package awais.instagrabber.repositories.responses.giphy; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.Objects; + +public class GiphyGifResults { + private final List giphyGifs; + private final List giphy; + + public GiphyGifResults(final List giphyGifs, final List giphy) { + this.giphyGifs = giphyGifs; + this.giphy = giphy; + } + + public List getGiphyGifs() { + return giphyGifs; + } + + public List getGiphy() { + return giphy; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GiphyGifResults that = (GiphyGifResults) o; + return Objects.equals(giphyGifs, that.giphyGifs) && + Objects.equals(giphy, that.giphy); + } + + @Override + public int hashCode() { + return Objects.hash(giphyGifs, giphy); + } + + @NonNull + @Override + public String toString() { + return "GiphyGifResults{" + + "giphyGifs=" + giphyGifs + + ", giphy=" + giphy + + '}'; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/notification/Notification.kt b/app/src/main/java/awais/instagrabber/repositories/responses/notification/Notification.kt new file mode 100644 index 0000000..0cd3577 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/notification/Notification.kt @@ -0,0 +1,11 @@ +package awais.instagrabber.repositories.responses.notification + +import awais.instagrabber.models.enums.NotificationType +import awais.instagrabber.models.enums.NotificationType.Companion.valueOfType + +class Notification(val args: NotificationArgs, + private val storyType: Int, + val pk: String) { + val type: NotificationType? + get() = valueOfType(storyType) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationArgs.java b/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationArgs.java new file mode 100644 index 0000000..c984b80 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationArgs.java @@ -0,0 +1,90 @@ +package awais.instagrabber.repositories.responses.notification; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import awais.instagrabber.utils.TextUtils; + +public class NotificationArgs { + private final String text; + private final String richText; + private final long profileId; + private final String profileImage; + private final List media; + private final double timestamp; + private final String profileName; + private final String fullName; // for AYML, not naturally generated + private final boolean isVerified; // mostly for AYML, not sure about notif + + public NotificationArgs(final String text, + final String richText, // for AYML, this is the algorithm + final long profileId, + final String profileImage, + final List media, + final double timestamp, + final String profileName, + final String fullName, + final boolean isVerified) { + this.text = text; + this.richText = richText; + this.profileId = profileId; + this.profileImage = profileImage; + this.media = media; + this.timestamp = timestamp; + this.profileName = profileName; + this.fullName = fullName; + this.isVerified = isVerified; + } + + public String getText() { + return text == null ? cleanRichText(richText) : text; + } + + public long getUserId() { + return profileId; + } + + public String getProfilePic() { + return profileImage; + } + + public String getUsername() { + return profileName; + } + + public String getFullName() { + return fullName; + } + + public List getMedia() { + return media; + } + + public double getTimestamp() { + return timestamp; + } + + public boolean isVerified() { + return isVerified; + } + + @NonNull + public String getDateTime() { + return TextUtils.epochSecondToString(Math.round(timestamp)); + } + + private String cleanRichText(final String raw) { + if (raw == null) return null; + final Matcher matcher = Pattern.compile("\\{[\\p{L}\\d._]+\\|000000\\|1\\|user\\?id=\\d+\\}").matcher(raw); + String result = raw; + while (matcher.find()) { + final String richObject = raw.substring(matcher.start(), matcher.end()); + final String username = richObject.split("\\|")[0].substring(1); + result = result.replace(richObject, username); + } + return result; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationCounts.kt b/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationCounts.kt new file mode 100644 index 0000000..9e49116 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationCounts.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.notification + +class NotificationCounts(val commentLikes: Int, + val usertags: Int, + val likes: Int, + val comments: Int, + val relationships: Int, + val photosOfYou: Int, + val requests: Int) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationImage.kt b/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationImage.kt new file mode 100644 index 0000000..4821018 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationImage.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses.notification + +class NotificationImage(val id: String, val image: String) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.kt new file mode 100644 index 0000000..b81e8c5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.repositories.responses.saved + +class CollectionsListResponse // this.numResults = numResults; +(val isMoreAvailable: Boolean, + val nextMaxId: String, + val maxId: String, + val status: String, // final int numResults, + // public int getNumResults() { + // return numResults; + // } + // private final int numResults; + val items: List) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.kt b/app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.kt new file mode 100644 index 0000000..02732c9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.repositories.responses.saved + +import awais.instagrabber.repositories.responses.Media +import java.io.Serializable + +class SavedCollection(val collectionId: String, + val collectionName: String, + val collectionType: String, + val collectionMediaCount: Int, + // coverMedia or coverMediaList: only one is defined + val coverMedia: Media, + val coverMediaList: List) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java b/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java new file mode 100644 index 0000000..9f56b76 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java @@ -0,0 +1,270 @@ +package awais.instagrabber.repositories.responses.search; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Place; +import awais.instagrabber.repositories.responses.User; + +public class SearchItem { + private static final String TAG = SearchItem.class.getSimpleName(); + + private final User user; + private final Place place; + private final Hashtag hashtag; + private final int position; + + private boolean isRecent = false; + private boolean isFavorite = false; + + public SearchItem(final User user, + final Place place, + final Hashtag hashtag, + final int position) { + this.user = user; + this.place = place; + this.hashtag = hashtag; + this.position = position; + } + + public User getUser() { + return user; + } + + public Place getPlace() { + return place; + } + + public Hashtag getHashtag() { + return hashtag; + } + + public int getPosition() { + return position; + } + + public boolean isRecent() { + return isRecent; + } + + public void setRecent(final boolean recent) { + isRecent = recent; + } + + public boolean isFavorite() { + return isFavorite; + } + + public void setFavorite(final boolean favorite) { + isFavorite = favorite; + } + + @Nullable + public FavoriteType getType() { + if (user != null) { + return FavoriteType.USER; + } + if (hashtag != null) { + return FavoriteType.HASHTAG; + } + if (place != null) { + return FavoriteType.LOCATION; + } + return null; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SearchItem that = (SearchItem) o; + return Objects.equals(user, that.user) && + Objects.equals(place, that.place) && + Objects.equals(hashtag, that.hashtag); + } + + @Override + public int hashCode() { + return Objects.hash(user, place, hashtag); + } + + @NonNull + @Override + public String toString() { + return "SearchItem{" + + "user=" + user + + ", place=" + place + + ", hashtag=" + hashtag + + ", position=" + position + + ", isRecent=" + isRecent + + '}'; + } + + @NonNull + public static List fromRecentSearch(final List recentSearches) { + if (recentSearches == null) return Collections.emptyList(); + return recentSearches.stream() + .map(SearchItem::fromRecentSearch) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Nullable + private static SearchItem fromRecentSearch(final RecentSearch recentSearch) { + if (recentSearch == null) return null; + try { + final FavoriteType type = recentSearch.getType(); + final SearchItem searchItem; + switch (type) { + case USER: + searchItem = new SearchItem(getUser(recentSearch), null, null, 0); + break; + case HASHTAG: + searchItem = new SearchItem(null, null, getHashtag(recentSearch), 0); + break; + case LOCATION: + searchItem = new SearchItem(null, getPlace(recentSearch), null, 0); + break; + default: + return null; + } + searchItem.setRecent(true); + return searchItem; + } catch (Exception e) { + Log.e(TAG, "fromRecentSearch: ", e); + } + return null; + } + + public static List fromFavorite(final List favorites) { + if (favorites == null) { + return Collections.emptyList(); + } + return favorites.stream() + .map(SearchItem::fromFavorite) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Nullable + private static SearchItem fromFavorite(final Favorite favorite) { + if (favorite == null) return null; + final FavoriteType type = favorite.getType(); + if (type == null) return null; + final SearchItem searchItem; + switch (type) { + case USER: + searchItem = new SearchItem(getUser(favorite), null, null, 0); + break; + case HASHTAG: + searchItem = new SearchItem(null, null, getHashtag(favorite), 0); + break; + case LOCATION: + final Place place = getPlace(favorite); + if (place == null) return null; + searchItem = new SearchItem(null, place, null, 0); + break; + default: + return null; + } + searchItem.setFavorite(true); + return searchItem; + } + + @NonNull + private static User getUser(@NonNull final RecentSearch recentSearch) { + return new User( + Long.parseLong(recentSearch.getIgId()), + recentSearch.getUsername(), + recentSearch.getName(), + false, + recentSearch.getPicUrl(), + false + ); + } + + @NonNull + private static User getUser(@NonNull final Favorite favorite) { + return new User( + 0, + favorite.getQuery(), + favorite.getDisplayName(), + false, + favorite.getPicUrl(), + false + ); + } + + @NonNull + private static Hashtag getHashtag(@NonNull final RecentSearch recentSearch) { + return new Hashtag( + recentSearch.getIgId(), + recentSearch.getName(), + 0, + null, + null + ); + } + + @NonNull + private static Hashtag getHashtag(@NonNull final Favorite favorite) { + return new Hashtag( + "0", + favorite.getQuery(), + 0, + null, + null + ); + } + + @NonNull + private static Place getPlace(@NonNull final RecentSearch recentSearch) { + final Location location = new Location( + Long.parseLong(recentSearch.getIgId()), + recentSearch.getName(), + recentSearch.getName(), + null, null, 0, 0 + ); + return new Place( + location, + recentSearch.getName(), + null, + null, + null + ); + } + + @Nullable + private static Place getPlace(@NonNull final Favorite favorite) { + try { + final Location location = new Location( + Long.parseLong(favorite.getQuery()), + favorite.getDisplayName(), + favorite.getDisplayName(), + null, null, 0, 0 + ); + return new Place( + location, + favorite.getDisplayName(), + null, + null, + null + ); + } catch (Exception e) { + Log.e(TAG, "getPlace: ", e); + return null; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchResponse.kt new file mode 100644 index 0000000..932af2a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchResponse.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.repositories.responses.search + +data class SearchResponse( + // app + val list: List?, + // browser + val users: List?, + val places: List?, + val hashtags: List?, + // universal + val status: String?, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/ArchiveResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ArchiveResponse.kt new file mode 100644 index 0000000..d9c990d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ArchiveResponse.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.stories + +data class ArchiveResponse( + val numResults: Int, + val maxId: String?, + val moreAvailable: Boolean, + val status: String, + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/Broadcast.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/Broadcast.kt new file mode 100644 index 0000000..a27efba --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/Broadcast.kt @@ -0,0 +1,15 @@ +package awais.instagrabber.repositories.responses.stories + +import awais.instagrabber.repositories.responses.User +import java.io.Serializable + +data class Broadcast( + val id: String?, + val dashPlaybackUrl: String?, + val dashAbrPlaybackUrl: String?, // adaptive quality + val viewerCount: Double?, // always .0 + val muted: Boolean?, + val coverFrameUrl: String?, + val broadcastOwner: User?, + val publishedTime: Long? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/CoverMedia.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/CoverMedia.kt new file mode 100644 index 0000000..47d7a64 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/CoverMedia.kt @@ -0,0 +1,5 @@ +package awais.instagrabber.repositories.responses.stories + +import awais.instagrabber.repositories.responses.ImageUrl + +data class CoverMedia(val croppedImageVersion: ImageUrl) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/PollSticker.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/PollSticker.kt new file mode 100644 index 0000000..537a3d2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/PollSticker.kt @@ -0,0 +1,13 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class PollSticker( + val pollId: Long, + val question: String?, + val tallies: List, + var viewerVote: Int? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/QuestionSticker.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/QuestionSticker.kt new file mode 100644 index 0000000..8780129 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/QuestionSticker.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class QuestionSticker( + val questionType: String, + val questionId: Long, + val question: String +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/QuizSticker.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/QuizSticker.kt new file mode 100644 index 0000000..4158490 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/QuizSticker.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class QuizSticker( + val quizId: Long, + val question: String, + val tallies: List, + var viewerAnswer: Int?, + val correctAnswer: Int +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsMediaResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsMediaResponse.kt new file mode 100644 index 0000000..7a165a6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsMediaResponse.kt @@ -0,0 +1,8 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable + +data class ReelsMediaResponse( + val status: String?, + val reels: Map? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsResponse.kt new file mode 100644 index 0000000..c23114c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsResponse.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable + +data class ReelsResponse( + val status: String?, + val reel: Story?, // users + val story: Story?, // hashtag and locations (unused) + val broadcast: Broadcast? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsTrayResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsTrayResponse.kt new file mode 100644 index 0000000..12d7227 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsTrayResponse.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable + +data class ReelsTrayResponse( + val status: String?, + val tray: List?, + val broadcasts: List? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/SliderSticker.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/SliderSticker.kt new file mode 100644 index 0000000..ca6a536 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/SliderSticker.kt @@ -0,0 +1,16 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class SliderSticker( + val sliderId: Long, + val question: String, + val emoji: String?, + val viewerCanVote: Boolean?, + val viewerVote: Double?, + val sliderVoteAverage: Double?, + val sliderVoteCount: Int?, +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/Story.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/Story.kt new file mode 100644 index 0000000..eb75944 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/Story.kt @@ -0,0 +1,31 @@ +package awais.instagrabber.repositories.responses.stories + +import awais.instagrabber.repositories.responses.ImageUrl +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.utils.TextUtils +import java.io.Serializable + +data class Story( + // universal + val id: String? = null, + val latestReelMedia: Long? = null, // = timestamp + val mediaCount: Int? = null, + // for stories and highlights + val seen: Long? = null, + val user: User? = null, + // for stories + val muted: Boolean? = null, + val hasBestiesMedia: Boolean? = null, + val items: List? = null, // may be null + // for highlights + val coverMedia: CoverMedia? = null, + val title: String? = null, + // for archives + val coverImageVersion: ImageUrl? = null, + // invented fields + val broadcast: Broadcast? = null, // does not naturally occur +) : Serializable { + val dateTime: String + get() = if (latestReelMedia != null) TextUtils.epochSecondToString(latestReelMedia) else "" + // note that archives have property "timestamp" but is ignored +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryAppAttribution.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryAppAttribution.kt new file mode 100644 index 0000000..bf8e524 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryAppAttribution.kt @@ -0,0 +1,18 @@ +package awais.instagrabber.repositories.responses.stories + +import android.net.Uri +import java.io.Serializable + +// https://github.com/austinhuang0131/barinsta/issues/1151 +data class StoryAppAttribution( + val name: String?, + val appActionText: String?, + val contentUrl: String? +) : Serializable { + val url: String? + get() { + val uri = Uri.parse(contentUrl) + return if (uri.getHost().equals("open.spotify.com")) contentUrl?.split("?")?.get(0) + else contentUrl + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryCta.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryCta.kt new file mode 100644 index 0000000..7043a3e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryCta.kt @@ -0,0 +1,10 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class StoryCta( + val webUri: String? +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMedia.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMedia.kt new file mode 100644 index 0000000..399e11c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMedia.kt @@ -0,0 +1,62 @@ +package awais.instagrabber.repositories.responses.stories + +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.models.enums.MediaItemType.Companion.valueOf +import awais.instagrabber.repositories.responses.ImageVersions2 +import awais.instagrabber.repositories.responses.MediaCandidate +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.utils.TextUtils +import java.io.Serializable + +data class StoryMedia( + // inherited from Media + val pk: Long = -1, + val id: String = "", + val takenAt: Long = -1, + val user: User? = null, + val canReshare: Boolean = false, + val imageVersions2: ImageVersions2? = null, + val originalWidth: Int = 0, + val originalHeight: Int = 0, + val mediaType: Int = 0, + val isReelMedia: Boolean = false, + val videoVersions: List? = null, + val hasAudio: Boolean = false, + val videoDuration: Double = 0.0, + val viewCount: Long = 0, + val title: String? = null, + // story-specific + val canReply: Boolean = false, + val linkText: String? = null, // required for story_cta + // stickers + val reelMentions: List? = null, + val storyHashtags: List? = null, + val storyLocations: List? = null, + val storyFeedMedia: List? = null, + val storyPolls: List? = null, + val storyQuestions: List? = null, + val storyQuizs: List? = null, + val storyCta: List? = null, + val storySliders: List? = null, + // spotify/soundcloud button, not a sticker + val storyAppAttribution: StoryAppAttribution? = null +) : Serializable { + private var dateString: String? = null + var position = 0 + var isCurrentSlide = false + + // TODO use extension once all usages are converted to kotlin + // val date: String by lazy { + // if (takenAt <= 0) "" else Utils.datetimeParser.format(Date(takenAt * 1000L)) + // } + val type: MediaItemType? + get() = valueOf(mediaType) + + val date: String + get() { + if (takenAt <= 0) return "" + if (dateString != null) return dateString ?: "" + dateString = TextUtils.epochSecondToString(takenAt) + return dateString ?: "" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMediaResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMediaResponse.kt new file mode 100644 index 0000000..7889b57 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMediaResponse.kt @@ -0,0 +1,14 @@ +package awais.instagrabber.repositories.responses.stories + +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.repositories.responses.ImageVersions2 +import awais.instagrabber.repositories.responses.MediaCandidate +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.utils.TextUtils +import java.io.Serializable + +data class StoryMediaResponse( + val items: List?, // length 1 + val status: String? + // ignoring pagination properties +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/StorySticker.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StorySticker.kt new file mode 100644 index 0000000..1f154e3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StorySticker.kt @@ -0,0 +1,19 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class StorySticker( + // only ONE object should exist + val user: User?, // reel_mentions + val hashtag: Hashtag?, // story_hashtags + val location: Location?, // story_locations + val mediaId: String?, // story_feed_media + val pollSticker: PollSticker?, // story_polls + val questionSticker: QuestionSticker?, // story_questions + val quizSticker: QuizSticker?, // story_quizs + val links: List?, // story_cta, requires link_text from the story + val sliderSticker: SliderSticker? // story_sliders +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryStickerResponse.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryStickerResponse.kt new file mode 100644 index 0000000..9a765ec --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryStickerResponse.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.repositories.responses.stories + +data class StoryStickerResponse(val status: String?) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/stories/Tally.kt b/app/src/main/java/awais/instagrabber/repositories/responses/stories/Tally.kt new file mode 100644 index 0000000..d71f166 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/stories/Tally.kt @@ -0,0 +1,11 @@ +package awais.instagrabber.repositories.responses.stories + +import java.io.Serializable +import awais.instagrabber.repositories.responses.Hashtag +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.User + +data class Tally( + val text: String, + val count: Int +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/serializers/CaptionDeserializer.java b/app/src/main/java/awais/instagrabber/repositories/serializers/CaptionDeserializer.java new file mode 100644 index 0000000..9db0278 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/serializers/CaptionDeserializer.java @@ -0,0 +1,41 @@ +package awais.instagrabber.repositories.serializers; + +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +import java.lang.reflect.Type; + +import awais.instagrabber.repositories.responses.Caption; + +public class CaptionDeserializer implements JsonDeserializer { + + private static final String TAG = CaptionDeserializer.class.getSimpleName(); + + @Override + public Caption deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + final Caption caption = new Gson().fromJson(json, Caption.class); + final JsonObject jsonObject = json.getAsJsonObject(); + if (jsonObject.has("pk")) { + JsonElement elem = jsonObject.get("pk"); + if (elem != null && !elem.isJsonNull()) { + if (!elem.isJsonPrimitive()) return caption; + String pkString = elem.getAsString(); + if (pkString.contains("_")) { + pkString = pkString.substring(0, pkString.indexOf("_")); + } + try { + caption.setPk(pkString); + } catch (NumberFormatException e) { + Log.e(TAG, "deserialize: ", e); + } + } + } + return caption; + } +} diff --git a/app/src/main/java/awais/instagrabber/services/ActivityCheckerService.java b/app/src/main/java/awais/instagrabber/services/ActivityCheckerService.java new file mode 100644 index 0000000..8de0275 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/services/ActivityCheckerService.java @@ -0,0 +1,150 @@ +package awais.instagrabber.services; + +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.Handler; +import android.os.IBinder; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import java.util.ArrayList; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.repositories.responses.notification.NotificationCounts; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.webservices.NewsService; +import awais.instagrabber.webservices.ServiceCallback; + +public class ActivityCheckerService extends Service { + private static final String TAG = "ActivityCheckerService"; + private static final int INITIAL_DELAY_MILLIS = 200; + private static final int DELAY_MILLIS = 60000; + + private Handler handler; + private NewsService newsService; + private ServiceCallback cb; + private NotificationManagerCompat notificationManager; + + private final IBinder binder = new LocalBinder(); + private final Runnable runnable = () -> { + newsService.fetchActivityCounts(cb); + }; + + public class LocalBinder extends Binder { + public ActivityCheckerService getService() { + return ActivityCheckerService.this; + } + } + + @Override + public void onCreate() { + notificationManager = NotificationManagerCompat.from(getApplicationContext()); + newsService = NewsService.getInstance(); + handler = new Handler(); + cb = new ServiceCallback() { + @Override + public void onSuccess(final NotificationCounts result) { + try { + if (result == null) return; + final List notification = getNotificationString(result); + if (notification == null) return; + showNotification(notification); + } finally { + handler.postDelayed(runnable, DELAY_MILLIS); + } + } + + @Override + public void onFailure(final Throwable t) {} + }; + } + + @Override + public IBinder onBind(Intent intent) { + startChecking(); + return binder; + } + + @Override + public boolean onUnbind(final Intent intent) { + stopChecking(); + return super.onUnbind(intent); + } + + private void startChecking() { + handler.postDelayed(runnable, INITIAL_DELAY_MILLIS); + } + + private void stopChecking() { + handler.removeCallbacks(runnable); + } + + private List getNotificationString(final NotificationCounts result) { + final List toReturn = new ArrayList<>(2); + final List list = new ArrayList<>(); + int count = 0; + if (result.getRelationships() != 0) { + list.add(getString(R.string.activity_count_relationship, result.getRelationships())); + count += result.getRelationships(); + } + if (result.getRequests() != 0) { + list.add(getString(R.string.activity_count_requests, result.getRequests())); + count += result.getRequests(); + } + if (result.getUsertags() != 0) { + list.add(getString(R.string.activity_count_usertags, result.getUsertags())); + count += result.getUsertags(); + } + if (result.getPhotosOfYou() != 0) { + list.add(getString(R.string.activity_count_poy, result.getPhotosOfYou())); + count += result.getPhotosOfYou(); + } + if (result.getComments() != 0) { + list.add(getString(R.string.activity_count_comments, result.getComments())); + count += result.getComments(); + } + if (result.getCommentLikes() != 0) { + list.add(getString(R.string.activity_count_commentlikes, result.getCommentLikes())); + count += result.getCommentLikes(); + } + if (result.getLikes() != 0) { + list.add(getString(R.string.activity_count_likes, result.getLikes())); + count += result.getLikes(); + } + if (list.isEmpty()) return null; + toReturn.add(TextUtils.join(", ", list)); + toReturn.add(getResources().getQuantityString(R.plurals.activity_count_total, count, count)); + return toReturn; + } + + private void showNotification(final List notificationString) { + final Notification notification = new NotificationCompat.Builder(this, Constants.ACTIVITY_CHANNEL_ID) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setSmallIcon(R.drawable.ic_notif) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentTitle(notificationString.get(1)) + .setContentText(notificationString.get(0)) + .setStyle(new NotificationCompat.BigTextStyle().bigText(notificationString.get(0))) + .setContentIntent(getPendingIntent()) + .build(); + notificationManager.notify(Constants.ACTIVITY_NOTIFICATION_ID, notification); + } + + @NonNull + private PendingIntent getPendingIntent() { + final Intent intent = new Intent(getApplicationContext(), MainActivity.class) + .setAction(Constants.ACTION_SHOW_ACTIVITY) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + return PendingIntent.getActivity(getApplicationContext(), Constants.SHOW_ACTIVITY_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } +} diff --git a/app/src/main/java/awais/instagrabber/services/BootCompletedReceiver.java b/app/src/main/java/awais/instagrabber/services/BootCompletedReceiver.java new file mode 100644 index 0000000..8594d8b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/services/BootCompletedReceiver.java @@ -0,0 +1,27 @@ +package awais.instagrabber.services; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import java.util.Objects; + +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.TextUtils; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class BootCompletedReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + if (!Objects.equals(intent.getAction(), "android.intent.action.BOOT_COMPLETED")) return; + final boolean enabled = settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH); + if (!enabled) return; + final String cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; + if (!isLoggedIn) return; + DMSyncAlarmReceiver.setAlarm(context); + } +} diff --git a/app/src/main/java/awais/instagrabber/services/DMSyncAlarmReceiver.java b/app/src/main/java/awais/instagrabber/services/DMSyncAlarmReceiver.java new file mode 100644 index 0000000..aec895b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/services/DMSyncAlarmReceiver.java @@ -0,0 +1,87 @@ +package awais.instagrabber.services; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; + +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.utils.Constants; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class DMSyncAlarmReceiver extends BroadcastReceiver { + private static final String TAG = DMSyncAlarmReceiver.class.getSimpleName(); + + @Override + public void onReceive(final Context context, final Intent intent) { + final boolean enabled = settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH); + if (!enabled) { + // If somehow the alarm was triggered even when auto refresh is disabled + cancelAlarm(context); + return; + } + try { + final Context applicationContext = context.getApplicationContext(); + ContextCompat.startForegroundService(applicationContext, new Intent(applicationContext, DMSyncService.class)); + } catch (Exception e) { + Log.e(TAG, "onReceive: ", e); + } + } + + public static void setAlarm(@NonNull final Context context) { + Log.d(TAG, "setting DMSyncService Alarm"); + final AlarmManager alarmManager = getAlarmManager(context); + if (alarmManager == null) return; + final PendingIntent pendingIntent = getPendingIntent(context); + alarmManager.setInexactRepeating(AlarmManager.RTC, System.currentTimeMillis(), getIntervalMillis(), pendingIntent); + } + + public static void cancelAlarm(@NonNull final Context context) { + Log.d(TAG, "cancelling DMSyncService Alarm"); + final AlarmManager alarmManager = getAlarmManager(context); + if (alarmManager == null) return; + final PendingIntent pendingIntent = getPendingIntent(context); + alarmManager.cancel(pendingIntent); + } + + private static AlarmManager getAlarmManager(@NonNull final Context context) { + return (AlarmManager) context.getApplicationContext().getSystemService(Context.ALARM_SERVICE); + } + + private static PendingIntent getPendingIntent(@NonNull final Context context) { + final Context applicationContext = context.getApplicationContext(); + final Intent intent = new Intent(applicationContext, DMSyncAlarmReceiver.class); + return PendingIntent.getBroadcast(applicationContext, + Constants.DM_SYNC_SERVICE_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + private static long getIntervalMillis() { + int amount = settingsHelper.getInteger(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER); + if (amount <= 0) { + amount = 30; + } + final String unit = settingsHelper.getString(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT); + final TemporalUnit temporalUnit; + switch (unit) { + case "mins": + temporalUnit = ChronoUnit.MINUTES; + break; + default: + case "secs": + temporalUnit = ChronoUnit.SECONDS; + } + return Duration.of(amount, temporalUnit).toMillis(); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/services/DMSyncService.java b/app/src/main/java/awais/instagrabber/services/DMSyncService.java new file mode 100644 index 0000000..e970b6c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/services/DMSyncService.java @@ -0,0 +1,260 @@ +package awais.instagrabber.services; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.lifecycle.LifecycleService; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.db.datasources.DMLastNotifiedDataSource; +import awais.instagrabber.db.entities.DMLastNotified; +import awais.instagrabber.db.repositories.DMLastNotifiedRepository; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.managers.DirectMessagesManager; +import awais.instagrabber.managers.InboxManager; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.directmessages.DirectInbox; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.DirectThreadLastSeenAt; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.DMUtils; +import awais.instagrabber.utils.DateUtils; +import awais.instagrabber.utils.TextUtils; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class DMSyncService extends LifecycleService { + private static final String TAG = DMSyncService.class.getSimpleName(); + + private InboxManager inboxManager; + private DMLastNotifiedRepository dmLastNotifiedRepository; + private Map dmLastNotifiedMap; + + @Override + public void onCreate() { + super.onCreate(); + startForeground(Constants.DM_CHECK_NOTIFICATION_ID, buildForegroundNotification()); + Log.d(TAG, "onCreate: Service created"); + final DirectMessagesManager directMessagesManager = DirectMessagesManager.INSTANCE; + inboxManager = directMessagesManager.getInboxManager(); + final Context context = getApplicationContext(); + if (context == null) return; + dmLastNotifiedRepository = DMLastNotifiedRepository.getInstance(DMLastNotifiedDataSource.getInstance(context)); + } + + private void parseUnread(@NonNull final DirectInbox directInbox) { + dmLastNotifiedRepository.getAllDMDmLastNotified( + CoroutineUtilsKt.getContinuation((result, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "parseUnread: ", throwable); + dmLastNotifiedMap = Collections.emptyMap(); + parseUnreadActual(directInbox); + return; + } + dmLastNotifiedMap = result != null + ? result.stream().collect(Collectors.toMap(DMLastNotified::getThreadId, Function.identity())) + : Collections.emptyMap(); + parseUnreadActual(directInbox); + }), Dispatchers.getIO()) + ); + // Log.d(TAG, "inbox observer: " + directInbox); + } + + private void parseUnreadActual(@NonNull final DirectInbox directInbox) { + final List threads = directInbox.getThreads(); + final ImmutableMap.Builder> unreadMessagesMapBuilder = ImmutableMap.builder(); + if (threads == null) { + stopSelf(); + return; + } + for (final DirectThread thread : threads) { + if (thread.getMuted()) continue; + final boolean read = DMUtils.isRead(thread); + if (read) continue; + final List unreadMessages = getUnreadMessages(thread); + if (unreadMessages.isEmpty()) continue; + unreadMessagesMapBuilder.put(thread.getThreadId(), unreadMessages); + } + final Map> unreadMessagesMap = unreadMessagesMapBuilder.build(); + if (unreadMessagesMap.isEmpty()) { + stopSelf(); + return; + } + showNotification(directInbox, unreadMessagesMap); + final LocalDateTime now = LocalDateTime.now(); + // Update db + final ImmutableList.Builder lastNotifiedListBuilder = ImmutableList.builder(); + for (final Map.Entry> unreadMessagesEntry : unreadMessagesMap.entrySet()) { + final List unreadItems = unreadMessagesEntry.getValue(); + final DirectItem latestItem = unreadItems.get(unreadItems.size() - 1); + lastNotifiedListBuilder.add(new DMLastNotified(0, + unreadMessagesEntry.getKey(), + latestItem.getDate(), + now)); + } + dmLastNotifiedRepository.insertOrUpdateDMLastNotified( + lastNotifiedListBuilder.build(), + CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + try { + if (throwable != null) { + Log.e(TAG, "parseUnreadActual: ", throwable); + } + } finally { + stopSelf(); + } + }), Dispatchers.getIO()) + ); + } + + @NonNull + private List getUnreadMessages(@NonNull final DirectThread thread) { + final List items = thread.getItems(); + if (items == null) return Collections.emptyList(); + final DMLastNotified dmLastNotified = dmLastNotifiedMap.get(thread.getThreadId()); + final long viewerId = thread.getViewerId(); + final Map lastSeenAt = thread.getLastSeenAt(); + final ImmutableList.Builder unreadListBuilder = ImmutableList.builder(); + int count = 0; + for (final DirectItem item : items) { + if (item == null) continue; + if (item.getUserId() == viewerId) break; // Reached a message from the viewer, it is assumed the viewer has read the next messages + final boolean read = DMUtils.isRead(item, lastSeenAt, Collections.singletonList(viewerId)); + if (read) break; + if (dmLastNotified != null && dmLastNotified.getLastNotifiedMsgTs() != null && item.getDate() != null) { + if (count == 0 && DateUtils.isBeforeOrEqual(item.getDate(), dmLastNotified.getLastNotifiedMsgTs())) { + // The first unread item has been notified and hence all subsequent items can be ignored + // since the items are in desc timestamp order + break; + } + } + unreadListBuilder.add(item); + count++; + // Inbox style notification only allows 6 lines + if (count >= 6) break; + } + // Reversing, so that oldest messages are on top + return unreadListBuilder.build().reverse(); + } + + private void showNotification(final DirectInbox directInbox, + final Map> unreadMessagesMap) { + final NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager == null) return; + for (final Map.Entry> unreadMessagesEntry : unreadMessagesMap.entrySet()) { + final Optional directThreadOptional = getThread(directInbox, unreadMessagesEntry.getKey()); + if (!directThreadOptional.isPresent()) continue; + final DirectThread thread = directThreadOptional.get(); + final DirectItem firstDirectItem = thread.getFirstDirectItem(); + if (firstDirectItem == null) continue; + final List unreadMessages = unreadMessagesEntry.getValue(); + final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); + inboxStyle.setBigContentTitle(thread.getThreadTitle()); + for (final DirectItem item : unreadMessages) { + inboxStyle.addLine(DMUtils.getMessageString(thread, getResources(), thread.getViewerId(), item)); + } + final Notification notification = new NotificationCompat.Builder(this, Constants.DM_UNREAD_CHANNEL_ID) + .setStyle(inboxStyle) + .setSmallIcon(R.drawable.ic_round_mode_comment_24) + .setContentTitle(thread.getThreadTitle()) + .setContentText(DMUtils.getMessageString(thread, getResources(), thread.getViewerId(), firstDirectItem)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setGroup(Constants.GROUP_KEY_DM) + .setAutoCancel(true) + .setContentIntent(getThreadPendingIntent(thread.getThreadId(), thread.getThreadTitle())) + .build(); + notificationManager.notify(Constants.DM_UNREAD_PARENT_NOTIFICATION_ID, notification); + } + } + + private Optional getThread(@NonNull final DirectInbox directInbox, final String threadId) { + return directInbox.getThreads() + .stream() + .filter(thread -> Objects.equals(thread.getThreadId(), threadId)) + .findFirst(); + } + + @NonNull + private PendingIntent getThreadPendingIntent(final String threadId, final String threadTitle) { + final Intent intent = new Intent(getApplicationContext(), MainActivity.class) + .setAction(Constants.ACTION_SHOW_DM_THREAD) + .putExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_ID, threadId) + .putExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_TITLE, threadTitle) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + return PendingIntent.getActivity(getApplicationContext(), Constants.SHOW_DM_THREAD, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + super.onStartCommand(intent, flags, startId); + final String cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; + if (!isLoggedIn) { + stopSelf(); + return START_NOT_STICKY; + } + // Need to setup here if service was started by the boot completed receiver + CookieUtils.setupCookies(cookie); + final boolean notificationsEnabled = settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_NOTIFICATIONS); + inboxManager.getInbox().observe(this, inboxResource -> { + if (!notificationsEnabled || inboxResource == null || inboxResource.status != Resource.Status.SUCCESS) { + stopSelf(); + return; + } + final DirectInbox directInbox = inboxResource.data; + if (directInbox == null) { + stopSelf(); + return; + } + parseUnread(directInbox); + }); + Log.d(TAG, "onStartCommand: refreshing inbox"); + // inboxManager.refresh(); + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(@NonNull final Intent intent) { + super.onBind(intent); + return null; + } + + private Notification buildForegroundNotification() { + final Resources resources = getResources(); + return new NotificationCompat.Builder(this, Constants.SILENT_NOTIFICATIONS_CHANNEL_ID) + .setOngoing(true) + .setSound(null) + .setContentTitle(resources.getString(R.string.app_name)) + .setContentText(resources.getString(R.string.checking_for_new_messages)) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setGroup(Constants.GROUP_KEY_SILENT_NOTIFICATIONS) + .build(); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/services/DeleteImageIntentService.java b/app/src/main/java/awais/instagrabber/services/DeleteImageIntentService.java new file mode 100644 index 0000000..ceeb2c7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/services/DeleteImageIntentService.java @@ -0,0 +1,73 @@ +package awais.instagrabber.services; + +import android.app.IntentService; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationManagerCompat; +import androidx.documentfile.provider.DocumentFile; + +import java.util.Random; + +import awais.instagrabber.utils.TextUtils; + +public class DeleteImageIntentService extends IntentService { + private final static String TAG = "DeleteImageIntent"; + private static final int DELETE_IMAGE_SERVICE_REQUEST_CODE = 9010; + private static final Random random = new Random(); + + public static final String EXTRA_IMAGE_PATH = "extra_image_path"; + public static final String EXTRA_NOTIFICATION_ID = "extra_notification_id"; + public static final String DELETE_IMAGE_SERVICE = "delete_image_service"; + + public DeleteImageIntentService() { + super(DELETE_IMAGE_SERVICE); + } + + @Override + public void onCreate() { + super.onCreate(); + startService(new Intent(this, DeleteImageIntentService.class)); + } + + @Override + protected void onHandleIntent(@Nullable Intent intent) { + if (intent != null && Intent.ACTION_DELETE.equals(intent.getAction()) && intent.hasExtra(EXTRA_IMAGE_PATH)) { + final String path = intent.getStringExtra(EXTRA_IMAGE_PATH); + if (TextUtils.isEmpty(path)) return; + // final File file = new File(path); + final Uri parse = Uri.parse(path); + if (parse == null) return; + final DocumentFile file = DocumentFile.fromSingleUri(getApplicationContext(), parse); + boolean deleted; + if (file.exists()) { + deleted = file.delete(); + if (!deleted) { + Log.w(TAG, "onHandleIntent: file not deleted!"); + } + } else { + deleted = true; + } + if (deleted) { + final int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1); + NotificationManagerCompat.from(this).cancel(notificationId); + } + } + } + + @NonNull + public static PendingIntent pendingIntent(@NonNull final Context context, + @NonNull final DocumentFile imagePath, + final int notificationId) { + final Intent intent = new Intent(context, DeleteImageIntentService.class); + intent.setAction(Intent.ACTION_DELETE); + intent.putExtra(EXTRA_IMAGE_PATH, imagePath.getUri().toString()); + intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId); + return PendingIntent.getService(context, random.nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT); + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/AppExecutors.kt b/app/src/main/java/awais/instagrabber/utils/AppExecutors.kt new file mode 100644 index 0000000..a1a0c8e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/AppExecutors.kt @@ -0,0 +1,53 @@ +/* + * 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 awais.instagrabber.utils + +import android.os.Handler +import android.os.Looper +import com.google.common.util.concurrent.ListeningExecutorService +import com.google.common.util.concurrent.MoreExecutors +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Global executor pools for the whole application. + * + * + * Grouping tasks like this avoids the effects of task starvation (e.g. disk reads don't wait behind + * webservice requests). + */ +// TODO replace with kotlin coroutines and Dispatchers +object AppExecutors { + val diskIO: Executor = Executors.newSingleThreadExecutor() + val networkIO: Executor = Executors.newFixedThreadPool(3) + val mainThread: MainThreadExecutor = MainThreadExecutor() + val tasksThread: ListeningExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)) + + class MainThreadExecutor : Executor { + private val mainThreadHandler = Handler(Looper.getMainLooper()) + override fun execute(command: Runnable) { + mainThreadHandler.post(command) + } + + fun execute(command: Runnable?, delay: Int) { + mainThreadHandler.postDelayed(command!!, delay.toLong()) + } + + fun cancel(command: Runnable) { + mainThreadHandler.removeCallbacks(command) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/BarinstaDeepLinkHelper.kt b/app/src/main/java/awais/instagrabber/utils/BarinstaDeepLinkHelper.kt new file mode 100644 index 0000000..1517e15 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/BarinstaDeepLinkHelper.kt @@ -0,0 +1,23 @@ +package awais.instagrabber.utils + +import android.net.Uri +import androidx.core.net.toUri + +private const val domain = "barinsta" + +fun getDirectThreadDeepLink(threadId: String, threadTitle: String, isPending: Boolean = false): Uri = + "$domain://dm_thread/$threadId/$threadTitle?pending=${isPending}".toUri() + +fun getProfileDeepLink(username: String): Uri = "$domain://profile/$username".toUri() + +fun getPostDeepLink(shortCode: String): Uri = "$domain://post/$shortCode".toUri() + +fun getLocationDeepLink(locationId: Long): Uri = "$domain://location/$locationId".toUri() + +fun getLocationDeepLink(locationId: String): Uri = "$domain://location/$locationId".toUri() + +fun getHashtagDeepLink(hashtag: String): Uri = "$domain://hashtag/$hashtag".toUri() + +fun getNotificationsDeepLink(type: String, targetId: Long = 0): Uri = "$domain://notifications/$type?targetId=$targetId".toUri() + +fun getSearchDeepLink(): Uri = "$domain://search".toUri() \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/BitmapUtils.kt b/app/src/main/java/awais/instagrabber/utils/BitmapUtils.kt new file mode 100644 index 0000000..6da17fd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/BitmapUtils.kt @@ -0,0 +1,239 @@ +package awais.instagrabber.utils + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import android.util.LruCache +import androidx.core.util.Pair +import androidx.documentfile.provider.DocumentFile +import awais.instagrabber.utils.extensions.TAG +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +object BitmapUtils { + private val bitmapMemoryCache: LruCache + const val THUMBNAIL_SIZE = 200f + + @JvmStatic + fun addBitmapToMemoryCache(key: String, bitmap: Bitmap, force: Boolean) { + if (force || getBitmapFromMemCache(key) == null) { + bitmapMemoryCache.put(key, bitmap) + } + } + + @JvmStatic + fun getBitmapFromMemCache(key: String): Bitmap? { + return bitmapMemoryCache[key] + } + + @JvmStatic + suspend fun getThumbnail(context: Context, uri: Uri): BitmapResult? { + val key = uri.toString() + val cachedBitmap = getBitmapFromMemCache(key) + if (cachedBitmap != null) { + return BitmapResult(cachedBitmap, -1, -1) + } + return loadBitmap(context.contentResolver, uri, THUMBNAIL_SIZE, THUMBNAIL_SIZE, true) + } + + /** + * Loads bitmap from given Uri + * + * @param contentResolver [ContentResolver] to resolve the uri + * @param uri Uri from where Bitmap will be loaded + * @param reqWidth Required width + * @param reqHeight Required height + * @param addToCache true if the loaded bitmap should be added to the mem cache + */ + suspend fun loadBitmap( + contentResolver: ContentResolver?, + uri: Uri?, + reqWidth: Float, + reqHeight: Float, + addToCache: Boolean, + ): BitmapResult? = loadBitmap(contentResolver, uri, reqWidth, reqHeight, -1f, addToCache) + + /** + * Loads bitmap from given Uri + * + * @param contentResolver [ContentResolver] to resolve the uri + * @param uri Uri from where Bitmap will be loaded + * @param maxDimenSize Max size of the largest side of the image + * @param addToCache true if the loaded bitmap should be added to the mem cache + */ + suspend fun loadBitmap( + contentResolver: ContentResolver?, + uri: Uri?, + maxDimenSize: Float, + addToCache: Boolean, + ): BitmapResult? = loadBitmap(contentResolver, uri, -1f, -1f, maxDimenSize, addToCache) + + /** + * Loads bitmap from given Uri + * + * @param contentResolver [ContentResolver] to resolve the uri + * @param uri Uri from where [Bitmap] will be loaded + * @param reqWidth Required width (set to -1 if maxDimenSize provided) + * @param reqHeight Required height (set to -1 if maxDimenSize provided) + * @param maxDimenSize Max size of the largest side of the image (set to -1 if setting reqWidth and reqHeight) + * @param addToCache true if the loaded bitmap should be added to the mem cache + */ + private suspend fun loadBitmap( + contentResolver: ContentResolver?, + uri: Uri?, + reqWidth: Float, + reqHeight: Float, + maxDimenSize: Float, + addToCache: Boolean, + ): BitmapResult? = + if (contentResolver == null || uri == null) null else withContext(Dispatchers.IO) { + getBitmapResult(contentResolver, + uri, + reqWidth, + reqHeight, + maxDimenSize, + addToCache) + } + + fun getBitmapResult( + contentResolver: ContentResolver, + uri: Uri, + reqWidth: Float, + reqHeight: Float, + maxDimenSize: Float, + addToCache: Boolean, + ): BitmapResult? { + var bitmapOptions: BitmapFactory.Options + var actualReqWidth = reqWidth + var actualReqHeight = reqHeight + try { + contentResolver.openInputStream(uri).use { input -> + val outBounds = BitmapFactory.Options() + outBounds.inJustDecodeBounds = true + outBounds.inPreferredConfig = Bitmap.Config.ARGB_8888 + BitmapFactory.decodeStream(input, null, outBounds) + if (outBounds.outWidth == -1 || outBounds.outHeight == -1) return null + bitmapOptions = BitmapFactory.Options() + if (maxDimenSize > 0) { + // Raw height and width of image + val height = outBounds.outHeight + val width = outBounds.outWidth + val ratio = width.toFloat() / height + if (height > width) { + actualReqHeight = maxDimenSize + actualReqWidth = actualReqHeight * ratio + } else { + actualReqWidth = maxDimenSize + actualReqHeight = actualReqWidth / ratio + } + } + bitmapOptions.inSampleSize = calculateInSampleSize(outBounds, actualReqWidth, actualReqHeight) + } + } catch (e: Exception) { + Log.e(TAG, "loadBitmap: ", e) + return null + } + try { + contentResolver.openInputStream(uri).use { input -> + bitmapOptions.inPreferredConfig = Bitmap.Config.ARGB_8888 + val bitmap = BitmapFactory.decodeStream(input, null, bitmapOptions) + if (addToCache && bitmap != null) { + addBitmapToMemoryCache(uri.toString(), bitmap, true) + } + return BitmapResult(bitmap, actualReqWidth.toInt(), actualReqHeight.toInt()) + } + } catch (e: Exception) { + Log.e(TAG, "loadBitmap: ", e) + } + return null + } + + private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Float, reqHeight: Float): 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 / 2f + val halfWidth = width / 2f + // 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 + } + + /** + * Decodes the bounds of an image from its Uri and returns a pair of the dimensions + * + * @param uri the Uri of the image + * @return dimensions of the image + */ + @Throws(IOException::class) + fun decodeDimensions( + contentResolver: ContentResolver, + uri: Uri, + ): Pair? { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + contentResolver.openInputStream(uri).use { stream -> + BitmapFactory.decodeStream(stream, null, options) + return if (options.outWidth == -1 || options.outHeight == -1) null else Pair(options.outWidth, options.outHeight) + } + } + + @Throws(IOException::class) + fun convertToJpegAndSaveToFile(contentResolver: ContentResolver, bitmap: Bitmap, file: DocumentFile?): DocumentFile? { + val tempFile = file ?: DownloadUtils.getTempFile(null, "jpg") + contentResolver.openOutputStream(tempFile!!.uri).use { output -> + val compressResult = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output) + if (!compressResult) { + throw RuntimeException("Compression failed!") + } + } + return tempFile + } + + @JvmStatic + @Throws(Exception::class) + fun convertToJpegAndSaveToUri( + context: Context, + bitmap: Bitmap, + uri: Uri, + ) { + context.contentResolver.openOutputStream(uri).use { output -> + val compressResult = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output) + if (!compressResult) { + throw RuntimeException("Compression failed!") + } + } + } + + class BitmapResult(var bitmap: Bitmap?, var width: Int, var height: Int) + + init { + // Get max available VM memory, exceeding this amount will throw an + // OutOfMemory exception. Stored in kilobytes as LruCache takes an + // int in its constructor. + val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() + // Use 1/8th of the available memory for this memory cache. + val cacheSize: Int = maxMemory / 8 + bitmapMemoryCache = object : LruCache(cacheSize) { + override fun sizeOf(key: String, bitmap: Bitmap): Int { + // The cache size will be measured in kilobytes rather than + // number of items. + return bitmap.byteCount / 1024 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/CombinedDrawable.kt b/app/src/main/java/awais/instagrabber/utils/CombinedDrawable.kt new file mode 100644 index 0000000..d02dcc4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/CombinedDrawable.kt @@ -0,0 +1,157 @@ +/* + * This is the source code of Telegram for Android v. 5.x.x. + * It is licensed under GNU GPL v. 2 or later. + * You should have received a copy of the license in this archive (see LICENSE). + *

+ * Copyright Nikolai Kudashov, 2013-2018. + */ +package awais.instagrabber.utils + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable + +class CombinedDrawable : Drawable, Drawable.Callback { + val background: Drawable + val icon: Drawable? + private var left = 0 + private var top = 0 + private var iconWidth = 0 + private var iconHeight = 0 + private var backWidth = 0 + private var backHeight = 0 + private var offsetX = 0 + private var offsetY = 0 + private var fullSize = false + + constructor(backgroundDrawable: Drawable, iconDrawable: Drawable?, leftOffset: Int, topOffset: Int) { + background = backgroundDrawable + icon = iconDrawable + left = leftOffset + top = topOffset + if (iconDrawable != null) { + iconDrawable.callback = this + } + } + + constructor(backgroundDrawable: Drawable, iconDrawable: Drawable?) { + background = backgroundDrawable + icon = iconDrawable + if (iconDrawable != null) { + iconDrawable.callback = this + } + } + + fun setIconSize(width: Int, height: Int) { + iconWidth = width + iconHeight = height + } + + fun setCustomSize(width: Int, height: Int) { + backWidth = width + backHeight = height + } + + fun setIconOffset(x: Int, y: Int) { + offsetX = x + offsetY = y + } + + fun setFullsize(value: Boolean) { + fullSize = value + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + icon?.colorFilter = colorFilter + } + + override fun isStateful(): Boolean { + return icon?.isStateful ?: false + } + + override fun setState(stateSet: IntArray): Boolean { + icon?.state = stateSet + return true + } + + override fun getState(): IntArray { + return icon?.state ?: super.getState() + } + + override fun onStateChange(state: IntArray): Boolean { + return true + } + + override fun jumpToCurrentState() { + icon?.jumpToCurrentState() + } + + override fun getConstantState(): ConstantState? { + return icon?.constantState + } + + override fun draw(canvas: Canvas) { + background.bounds = bounds + background.draw(canvas) + if (icon == null) return + if (fullSize) { + val bounds = bounds + if (left != 0) { + icon.setBounds(bounds.left + left, bounds.top + top, bounds.right - left, bounds.bottom - top) + } else { + icon.bounds = bounds + } + } else { + val x: Int + val y: Int + if (iconWidth != 0) { + x = bounds.centerX() - iconWidth / 2 + left + offsetX + y = bounds.centerY() - iconHeight / 2 + top + offsetY + icon.setBounds(x, y, x + iconWidth, y + iconHeight) + } else { + x = bounds.centerX() - icon.intrinsicWidth / 2 + left + y = bounds.centerY() - icon.intrinsicHeight / 2 + top + icon.setBounds(x, y, x + icon.intrinsicWidth, y + icon.intrinsicHeight) + } + } + icon.draw(canvas) + } + + override fun setAlpha(alpha: Int) { + icon?.alpha = alpha + background.alpha = alpha + } + + override fun getIntrinsicWidth(): Int { + return if (backWidth != 0) backWidth else background.intrinsicWidth + } + + override fun getIntrinsicHeight(): Int { + return if (backHeight != 0) backHeight else background.intrinsicHeight + } + + override fun getMinimumWidth(): Int { + return if (backWidth != 0) backWidth else background.minimumWidth + } + + override fun getMinimumHeight(): Int { + return if (backHeight != 0) backHeight else background.minimumHeight + } + + override fun getOpacity(): Int { + return icon?.opacity ?: PixelFormat.UNKNOWN + } + + override fun invalidateDrawable(who: Drawable) { + invalidateSelf() + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { + scheduleSelf(what, `when`) + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + unscheduleSelf(what) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/ConcurrencyHelpers.kt b/app/src/main/java/awais/instagrabber/utils/ConcurrencyHelpers.kt new file mode 100644 index 0000000..709b93c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ConcurrencyHelpers.kt @@ -0,0 +1,240 @@ +package awais.instagrabber.utils + +import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicReference + +/** + * + * From https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7 + * + * A helper class to execute tasks sequentially in coroutines. + * + * Calling [afterPrevious] will always ensure that all previously requested work completes prior to + * calling the block passed. Any future calls to [afterPrevious] while the current block is running + * will wait for the current block to complete before starting. + */ +class SingleRunner { + /** + * A coroutine mutex implements a lock that may only be taken by one coroutine at a time. + */ + private val mutex = Mutex() + + /** + * Ensure that the block will only be executed after all previous work has completed. + * + * When several coroutines call afterPrevious at the same time, they will queue up in the order + * that they call afterPrevious. Then, one coroutine will enter the block at a time. + * + * In the following example, only one save operation (user or song) will be executing at a time. + * + * ``` + * class UserAndSongSaver { + * val singleRunner = SingleRunner() + * + * fun saveUser(user: User) { + * singleRunner.afterPrevious { api.post(user) } + * } + * + * fun saveSong(song: Song) { + * singleRunner.afterPrevious { api.post(song) } + * } + * } + * ``` + * + * @param block the code to run after previous work is complete. + */ + suspend fun afterPrevious(block: suspend () -> T): T { + // Before running the block, ensure that no other blocks are running by taking a lock on the + // mutex. + + // The mutex will be released automatically when we return. + + // If any other block were already running when we get here, it will wait for it to complete + // before entering the `withLock` block. + mutex.withLock { + return block() + } + } +} + +/** + * A controlled runner decides what to do when new tasks are run. + * + * By calling [joinPreviousOrRun], the new task will be discarded and the result of the previous task + * will be returned. This is useful when you want to ensure that a network request to the same + * resource does not flood. + * + * By calling [cancelPreviousThenRun], the old task will *always* be cancelled and then the new task will + * be run. This is useful in situations where a new event implies that the previous work is no + * longer relevant such as sorting or filtering a list. + */ +class ControlledRunner { + /** + * The currently active task. + * + * This uses an atomic reference to ensure that it's safe to update activeTask on both + * Dispatchers.Default and Dispatchers.Main which will execute coroutines on multiple threads at + * the same time. + */ + private val activeTask = AtomicReference?>(null) + + /** + * Cancel all previous tasks before calling block. + * + * When several coroutines call cancelPreviousThenRun at the same time, only one will run and + * the others will be cancelled. + * + * In the following example, only one sort operation will execute and any previous sorts will be + * cancelled. + * + * ``` + * class Products { + * val controlledRunner = ControlledRunner() + * + * fun sortAscending(): List { + * return controlledRunner.cancelPreviousThenRun { dao.loadSortedAscending() } + * } + * + * fun sortDescending(): List { + * return controlledRunner.cancelPreviousThenRun { dao.loadSortedDescending() } + * } + * } + * ``` + * + * @param block the code to run after previous work is cancelled. + * @return the result of block, if this call was not cancelled prior to returning. + */ + suspend fun cancelPreviousThenRun(block: suspend () -> T): T { + // fast path: if we already know about an active task, just cancel it right away. + activeTask.get()?.cancelAndJoin() + + return coroutineScope { + // Create a new coroutine, but don't start it until it's decided that this block should + // execute. In the code below, calling await() on newTask will cause this coroutine to + // start. + val newTask = async(start = LAZY) { + block() + } + + // When newTask completes, ensure that it resets activeTask to null (if it was the + // current activeTask). + newTask.invokeOnCompletion { + activeTask.compareAndSet(newTask, null) + } + + // Kotlin ensures that we only set result once since it's a val, even though it's set + // inside the while(true) loop. + val result: T + + // Loop until we are sure that newTask is ready to execute (all previous tasks are + // cancelled) + while (true) { + if (!activeTask.compareAndSet(null, newTask)) { + // some other task started before newTask got set to activeTask, so see if it's + // still running when we call get() here. If so, we can cancel it. + + // we will always start the loop again to see if we can set activeTask before + // starting newTask. + activeTask.get()?.cancelAndJoin() + // yield here to avoid a possible tight loop on a single threaded dispatcher + yield() + } else { + // happy path - we set activeTask so we are ready to run newTask + result = newTask.await() + break + } + } + + // Kotlin ensures that the above loop always sets result exactly once, so we can return + // it here! + result + } + } + + /** + * Don't run the new block if a previous block is running, instead wait for the previous block + * and return it's result. + * + * When several coroutines call jonPreviousOrRun at the same time, only one will run and + * the others will return the result from the winner. + * + * In the following example, only one network operation will execute at a time and any other + * requests will return the result from the "in flight" request. + * + * ``` + * class Products { + * val controlledRunner = ControlledRunner() + * + * fun fetchProducts(): List { + * return controlledRunner.joinPreviousOrRun { + * val results = api.fetchProducts() + * dao.insert(results) + * results + * } + * } + * } + * ``` + * + * @param block the code to run if and only if no other task is currently running + * @return the result of block, or if another task was running the result of that task instead. + */ + suspend fun joinPreviousOrRun(block: suspend () -> T): T { + // fast path: if there's already an active task, just wait for it and return the result + activeTask.get()?.let { + return it.await() + } + return coroutineScope { + // Create a new coroutine, but don't start it until it's decided that this block should + // execute. In the code below, calling await() on newTask will cause this coroutine to + // start. + val newTask = async(start = LAZY) { + block() + } + + newTask.invokeOnCompletion { + activeTask.compareAndSet(newTask, null) + } + + // Kotlin ensures that we only set result once since it's a val, even though it's set + // inside the while(true) loop. + val result: T + + // Loop until we figure out if we need to run newTask, or if there is a task that's + // already running we can join. + while (true) { + if (!activeTask.compareAndSet(null, newTask)) { + // some other task started before newTask got set to activeTask, so see if it's + // still running when we call get() here. There is a chance that it's already + // been completed before the call to get, in which case we need to start the + // loop over and try again. + val currentTask = activeTask.get() + if (currentTask != null) { + // happy path - we found the other task so use that one instead of newTask + newTask.cancel() + result = currentTask.await() + break + } else { + // retry path - the other task completed before we could get it, loop to try + // setting activeTask again. + + // call yield here in case we're executing on a single threaded dispatcher + // like Dispatchers.Main to allow other work to happen. + yield() + } + } else { + // happy path - we were able to set activeTask, so start newTask and return its + // result + result = newTask.await() + break + } + } + + // Kotlin ensures that the above loop always sets result exactly once, so we can return + // it here! + result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/Constants.kt b/app/src/main/java/awais/instagrabber/utils/Constants.kt new file mode 100644 index 0000000..7710379 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/Constants.kt @@ -0,0 +1,94 @@ +package awais.instagrabber.utils + +object Constants { + const val CRASH_REPORT_EMAIL = "barinsta@austinhuang.me" + + // int prefs, do not export + const val PREV_INSTALL_VERSION = "prevVersion" + const val BROWSER_UA_CODE = "browser_ua_code" + const val APP_UA_CODE = "app_ua_code" + + // never Export + const val COOKIE = "cookie" + + // deprecated: public static final String SHOW_QUICK_ACCESS_DIALOG = "show_quick_dlg"; + const val DEVICE_UUID = "device_uuid" + const val BROWSER_UA = "browser_ua" + const val APP_UA = "app_ua" + + //////////////////////// EXTRAS //////////////////////// + const val EXTRAS_USER = "user" + const val EXTRAS_HASHTAG = "hashtag" + const val EXTRAS_LOCATION = "location" + const val EXTRAS_USERNAME = "username" + const val EXTRAS_ID = "id" + const val EXTRAS_POST = "post" + const val EXTRAS_PROFILE = "profile" + const val EXTRAS_TYPE = "type" + const val EXTRAS_NAME = "name" + const val EXTRAS_STORIES = "stories" + const val EXTRAS_HIGHLIGHT = "highlight" + const val EXTRAS_INDEX = "index" + const val EXTRAS_THREAD_MODEL = "threadModel" + const val EXTRAS_FOLLOWERS = "followers" + const val EXTRAS_SHORTCODE = "shortcode" + const val EXTRAS_END_CURSOR = "endCursor" + const val FEED = "feed" + const val FEED_ORDER = "feedOrder" + + // Notification ids + const val ACTIVITY_NOTIFICATION_ID = 10 + const val DM_UNREAD_PARENT_NOTIFICATION_ID = 20 + const val DM_CHECK_NOTIFICATION_ID = 11 + + // see https://github.com/dilame/instagram-private-api/blob/master/src/core/constants.ts + // public static final String SUPPORTED_CAPABILITIES = "[ { \"name\": \"SUPPORTED_SDK_VERSIONS\", \"value\":" + + // " \"13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,31.0," + + // "32.0,33.0,34.0,35.0,36.0,37.0,38.0,39.0,40.0,41.0,42.0,43.0,44.0,45.0,46.0,47.0,48.0,49.0,50.0,51.0," + + // "52.0,53.0,54.0,55.0,56.0,57.0,58.0,59.0,60.0,61.0,62.0,63.0,64.0,65.0,66.0\" }, { \"name\": \"FACE_TRACKER_VERSION\", " + + // "\"value\": 12 }, { \"name\": \"segmentation\", \"value\": \"segmentation_enabled\" }, { \"name\": \"COMPRESSION\", " + + // "\"value\": \"ETC2_COMPRESSION\" }, { \"name\": \"world_tracker\", \"value\": \"world_tracker_enabled\" }, { \"name\": " + + // "\"gyroscope\", \"value\": \"gyroscope_enabled\" } ]"; + // public static final String SIGNATURE_VERSION = "4"; + // public static final String SIGNATURE_KEY = "9193488027538fd3450b83b7d05286d4ca9599a0f7eeed90d8c85925698a05dc"; + const val BREADCRUMB_KEY = "iN4\$aGr0m" + const val LOGIN_RESULT_CODE = 5000 + const val SKIPPED_VERSION = "skipped_version" + const val DEFAULT_TAB = "default_tab" + const val PREF_DARK_THEME = "dark_theme" + const val PREF_LIGHT_THEME = "light_theme" + const val DEFAULT_HASH_TAG_PIC = "https://www.instagram.com/static/images/hashtag/search-hashtag-default-avatar.png/1d8417c9a4f5.png" + const val SHARED_PREFERENCES_NAME = "settings" + const val PREF_POSTS_LAYOUT = "posts_layout" + const val PREF_PROFILE_POSTS_LAYOUT = "profile_posts_layout" + const val PREF_TOPIC_POSTS_LAYOUT = "topic_posts_layout" + const val PREF_HASHTAG_POSTS_LAYOUT = "hashtag_posts_layout" + const val PREF_LOCATION_POSTS_LAYOUT = "location_posts_layout" + const val PREF_LIKED_POSTS_LAYOUT = "liked_posts_layout" + const val PREF_TAGGED_POSTS_LAYOUT = "tagged_posts_layout" + const val PREF_SAVED_POSTS_LAYOUT = "saved_posts_layout" + const val PREF_EMOJI_VARIANTS = "emoji_variants" + const val PREF_REACTIONS = "reactions" + const val ACTIVITY_CHANNEL_ID = "activity" + const val ACTIVITY_CHANNEL_NAME = "Activity" + const val DOWNLOAD_CHANNEL_ID = "download" + const val DOWNLOAD_CHANNEL_NAME = "Downloads" + const val DM_UNREAD_CHANNEL_ID = "dmUnread" + const val DM_UNREAD_CHANNEL_NAME = "Messages" + const val SILENT_NOTIFICATIONS_CHANNEL_ID = "silentNotifications" + const val SILENT_NOTIFICATIONS_CHANNEL_NAME = "Silent notifications" + const val NOTIF_GROUP_NAME = "awais.instagrabber.InstaNotif" + const val GROUP_KEY_DM = "awais.instagrabber.MESSAGES" + const val GROUP_KEY_SILENT_NOTIFICATIONS = "awais.instagrabber.SILENT_NOTIFICATIONS" + const val SHOW_ACTIVITY_REQUEST_CODE = 1738 + const val SHOW_DM_THREAD = 2000 + const val DM_SYNC_SERVICE_REQUEST_CODE = 3000 + const val GLOBAL_NETWORK_ERROR_DIALOG_REQUEST_CODE = 7777 + const val ACTION_SHOW_ACTIVITY = "show_activity" + const val ACTION_SHOW_DM_THREAD = "show_dm_thread" + const val DM_THREAD_ACTION_EXTRA_THREAD_ID = "thread_id" + const val DM_THREAD_ACTION_EXTRA_THREAD_TITLE = "thread_title" + const val X_IG_APP_ID = "936619743392459" + const val EXTRA_INITIAL_URI = "initial_uri" + const val defaultDateTimeFormat = "hh:mm:ss a 'on' dd-MM-yyyy" +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/CookieUtils.kt b/app/src/main/java/awais/instagrabber/utils/CookieUtils.kt new file mode 100644 index 0000000..620aedf --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/CookieUtils.kt @@ -0,0 +1,106 @@ +@file:JvmName("CookieUtils") + +package awais.instagrabber.utils + +import android.content.Context +import android.util.Log +import android.webkit.CookieManager +import awais.instagrabber.db.repositories.AccountRepository +import java.net.CookiePolicy +import java.net.HttpCookie +import java.net.URI +import java.net.URISyntaxException +import java.util.regex.Pattern + +private const val TAG = "CookieUtils" +private val COOKIE_MANAGER = CookieManager.getInstance() + +@JvmField +val NET_COOKIE_MANAGER = java.net.CookieManager(null, CookiePolicy.ACCEPT_ALL) + +fun setupCookies(cookieRaw: String) { + val cookieStore = NET_COOKIE_MANAGER.cookieStore + if (cookieStore == null || TextUtils.isEmpty(cookieRaw)) { + return + } + if (cookieRaw == "LOGOUT") { + cookieStore.removeAll() + return + } + try { + val uri1 = URI("https://instagram.com") + val uri2 = URI("https://instagram.com/") + val uri3 = URI("https://i.instagram.com/") + for (cookie in cookieRaw.split("; ")) { + val strings = cookie.split("=", limit = 2) + val httpCookie = HttpCookie(strings[0].trim { it <= ' ' }, strings[1].trim { it <= ' ' }) + httpCookie.domain = ".instagram.com" + httpCookie.path = "/" + httpCookie.version = 0 + cookieStore.add(uri1, httpCookie) + cookieStore.add(uri2, httpCookie) + cookieStore.add(uri3, httpCookie) + } + } catch (e: URISyntaxException) { + Log.e(TAG, "", e) + } +} + +suspend fun removeAllAccounts(context: Context) { + NET_COOKIE_MANAGER.cookieStore.removeAll() + AccountRepository.getInstance(context).deleteAllAccounts() +} + +fun getUserIdFromCookie(cookies: String?): Long { + cookies ?: return 0 + val dsUserId = getCookieValue(cookies, "ds_user_id") ?: return 0 + try { + return dsUserId.toLong() + } catch (e: NumberFormatException) { + Log.e(TAG, "getUserIdFromCookie: ", e) + } + return 0 +} + +fun getCsrfTokenFromCookie(cookies: String): String? { + return getCookieValue(cookies, "csrftoken") +} + +private fun getCookieValue(cookies: String, name: String): String? { + val pattern = Pattern.compile("$name=(.+?);") + val matcher = pattern.matcher(cookies) + return if (matcher.find()) { + matcher.group(1) + } else null +} + +fun getCookie(webViewUrl: String?): String? { + val domains: List = listOfNotNull( + if (!TextUtils.isEmpty(webViewUrl)) webViewUrl else null, + "https://instagram.com", + "https://instagram.com/", + "http://instagram.com", + "http://instagram.com", + "https://www.instagram.com", + "https://www.instagram.com/", + "http://www.instagram.com", + "http://www.instagram.com/", + ) + return getLongestCookie(domains) +} + +private fun getLongestCookie(domains: List): String? { + var longestLength = 0 + var longestCookie: String? = null + for (domain in domains) { + val cookie = COOKIE_MANAGER.getCookie(domain) + if (cookie != null) { + val cookieLength = cookie.length + if (cookieLength > longestLength) { + longestCookie = cookie + longestLength = cookieLength + } + } + } + return longestCookie +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/CoroutineUtils.kt b/app/src/main/java/awais/instagrabber/utils/CoroutineUtils.kt new file mode 100644 index 0000000..6c28ae1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/CoroutineUtils.kt @@ -0,0 +1,19 @@ +package awais.instagrabber.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import java.util.function.BiConsumer +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext + +@JvmOverloads +fun getContinuation(onFinished: BiConsumer, dispatcher: CoroutineDispatcher = Dispatchers.Default): Continuation { + return object : Continuation { + override val context: CoroutineContext + get() = dispatcher + + override fun resumeWith(result: Result) { + onFinished.accept(result.getOrNull(), result.exceptionOrNull()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/CubicInterpolation.kt b/app/src/main/java/awais/instagrabber/utils/CubicInterpolation.kt new file mode 100644 index 0000000..cc57a9c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/CubicInterpolation.kt @@ -0,0 +1,38 @@ +package awais.instagrabber.utils + +import java.util.* + +class CubicInterpolation @JvmOverloads constructor(array: FloatArray, cubicTension: Int = 0) { + private val array: FloatArray + private val tangentFactor: Int + private val length: Int + private fun getTangent(k: Int): Float { + return tangentFactor * (getClippedInput(k + 1) - getClippedInput(k - 1)) / 2 + } + + fun interpolate(t: Float): Float { + val k = Math.floor(t.toDouble()).toInt() + val m = floatArrayOf(getTangent(k), getTangent(k + 1)) + val p = floatArrayOf(getClippedInput(k), getClippedInput(k + 1)) + val t1 = t - k + val t2 = t1 * t1 + val t3 = t1 * t2 + return (2 * t3 - 3 * t2 + 1) * p[0] + (t3 - 2 * t2 + t1) * m[0] + (-2 * t3 + 3 * t2) * p[1] + (t3 - t2) * m[1] + } + + private fun getClippedInput(i: Int): Float { + return if (i >= 0 && i < length) { + array[i] + } else array[clipClamp(i, length)] + } + + private fun clipClamp(i: Int, n: Int): Int { + return Math.max(0, Math.min(i, n - 1)) + } + + init { + this.array = Arrays.copyOf(array, array.size) + length = array.size + tangentFactor = 1 - Math.max(0, Math.min(1, cubicTension)) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/DMUtils.java b/app/src/main/java/awais/instagrabber/utils/DMUtils.java new file mode 100644 index 0000000..e5235e2 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/DMUtils.java @@ -0,0 +1,311 @@ +package awais.instagrabber.utils; + +import android.content.res.Resources; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import awais.instagrabber.R; +import awais.instagrabber.models.enums.DirectItemType; +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.ActionType; +import awais.instagrabber.repositories.responses.directmessages.DirectItem; +import awais.instagrabber.repositories.responses.directmessages.DirectItemAnimatedMedia; +import awais.instagrabber.repositories.responses.directmessages.DirectItemReelShare; +import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.DirectThreadLastSeenAt; +import awais.instagrabber.repositories.responses.directmessages.RavenExpiringMediaActionSummary; + +public final class DMUtils { + public static boolean isRead(@NonNull final DirectItem item, + @NonNull final Map lastSeenAt, + @NonNull final List userIdsToCheck) { + return lastSeenAt.entrySet() + .stream() + .filter(entry -> userIdsToCheck.contains(entry.getKey())) + .anyMatch(entry -> { + final DirectThreadLastSeenAt threadLastSeenAt = entry.getValue(); + if (threadLastSeenAt == null) return false; + final String userLastSeenTsString = threadLastSeenAt.getTimestamp(); + if (userLastSeenTsString == null) return false; + final long userTs = Long.parseLong(userLastSeenTsString); + final long itemTs = item.getTimestamp(); + return userTs >= itemTs; + }); + } + + public static boolean isRead(@NonNull final DirectThread thread) { + final boolean read; + // if (thread.getDirectStory() != null) { + // return false; + // } + final DirectItem item = thread.getFirstDirectItem(); + final long viewerId = thread.getViewerId(); + if (item != null && item.getUserId() == viewerId) { + // if last item was sent by user, then it is read (even though we have auto read unchecked?) + read = true; + } else { + final Map lastSeenAtMap = thread.getLastSeenAt(); + read = item != null && isRead(item, lastSeenAtMap, Collections.singletonList(viewerId)); + } + return read; + } + + public static String getMessageString(@NonNull final DirectThread thread, + final Resources resources, + final long viewerId, + final DirectItem item) { + final long senderId = item.getUserId(); + final DirectItemType itemType = item.getItemType(); + String subtitle = null; + final String username = getUsername(thread.getUsers(), senderId, viewerId, resources); + String message = ""; + if (itemType == null) { + message = resources.getString(R.string.dms_inbox_raven_message_unknown); + } else { + switch (itemType) { + case TEXT: + message = item.getText(); + break; + case LIKE: + message = item.getLike(); + break; + case LINK: + message = item.getLink().getText(); + break; + case PLACEHOLDER: + message = item.getPlaceholder().getMessage(); + break; + case MEDIA_SHARE: + final Media mediaShare = item.getMediaShare(); + User mediaShareUser = null; + if (mediaShare != null) { + mediaShareUser = mediaShare.getUser(); + } + subtitle = resources.getString(R.string.dms_inbox_shared_post, + username != null ? username : "", + mediaShareUser == null ? "" : mediaShareUser.getUsername()); + break; + case ANIMATED_MEDIA: + final DirectItemAnimatedMedia animatedMedia = item.getAnimatedMedia(); + subtitle = resources.getString(animatedMedia.isSticker() ? R.string.dms_inbox_shared_sticker + : R.string.dms_inbox_shared_gif, + username != null ? username : ""); + break; + case PROFILE: + subtitle = resources + .getString(R.string.dms_inbox_shared_profile, username != null ? username : "", item.getProfile().getUsername()); + break; + case LOCATION: + subtitle = resources + .getString(R.string.dms_inbox_shared_location, username != null ? username : "", item.getLocation().getName()); + break; + case MEDIA: { + final MediaItemType mediaType = item.getMedia().getType(); + subtitle = getMediaSpecificSubtitle(username, resources, mediaType); + break; + } + case STORY_SHARE: { + final String reelType = item.getStoryShare().getReelType(); + if (reelType == null) { + subtitle = item.getStoryShare().getTitle(); + } else { + final int format = reelType.equals("highlight_reel") + ? R.string.dms_inbox_shared_highlight + : R.string.dms_inbox_shared_story; + final Media media = item.getStoryShare().getMedia(); + User storyShareMediaUser = null; + if (media != null) { + storyShareMediaUser = media.getUser(); + } + subtitle = resources.getString(format, + username != null ? username : "", + storyShareMediaUser == null ? "" : storyShareMediaUser.getUsername()); + } + break; + } + case VOICE_MEDIA: + subtitle = resources.getString(R.string.dms_inbox_shared_voice, username != null ? username : ""); + break; + case ACTION_LOG: + subtitle = item.getActionLog().getDescription(); + break; + case VIDEO_CALL_EVENT: + subtitle = item.getVideoCallEvent().getDescription(); + break; + case CLIP: + final Media clip = item.getClip().getClip(); + User clipUser = null; + if (clip != null) { + clipUser = clip.getUser(); + } + subtitle = resources.getString(R.string.dms_inbox_shared_clip, + username != null ? username : "", + clipUser == null ? "" : clipUser.getUsername()); + break; + case FELIX_SHARE: + final Media video = item.getFelixShare().getVideo(); + User felixShareVideoUser = null; + if (video != null) { + felixShareVideoUser = video.getUser(); + } + subtitle = resources.getString(R.string.dms_inbox_shared_igtv, + username != null ? username : "", + felixShareVideoUser == null ? "" : felixShareVideoUser.getUsername()); + break; + case RAVEN_MEDIA: + subtitle = getRavenMediaSubtitle(item, resources, username); + break; + case REEL_SHARE: + final DirectItemReelShare reelShare = item.getReelShare(); + if (reelShare == null) { + subtitle = ""; + break; + } + final String reelType = reelShare.getType(); + switch (reelType) { + case "reply": + if (viewerId == item.getUserId()) { + subtitle = resources.getString(R.string.dms_inbox_replied_story_outgoing, reelShare.getText()); + } else { + subtitle = resources + .getString(R.string.dms_inbox_replied_story_incoming, username != null ? username : "", reelShare.getText()); + } + break; + case "mention": + if (viewerId == item.getUserId()) { + // You mentioned the other person + final long mentionedUserId = item.getReelShare().getMentionedUserId(); + final String otherUsername = getUsername(thread.getUsers(), mentionedUserId, viewerId, resources); + subtitle = resources.getString(R.string.dms_inbox_mentioned_story_outgoing, otherUsername); + } else { + // They mentioned you + subtitle = resources.getString(R.string.dms_inbox_mentioned_story_incoming, username != null ? username : ""); + } + break; + case "reaction": + if (viewerId == item.getUserId()) { + subtitle = resources.getString(R.string.dms_inbox_reacted_story_outgoing, reelShare.getText()); + } else { + subtitle = resources + .getString(R.string.dms_inbox_reacted_story_incoming, username != null ? username : "", reelShare.getText()); + } + break; + default: + subtitle = ""; + break; + } + break; + case XMA: + subtitle = resources.getString(R.string.dms_inbox_shared_sticker, username != null ? username : ""); + break; + default: + message = resources.getString(R.string.dms_inbox_raven_message_unknown); + } + } + if (subtitle == null) { + if (thread.isGroup() || (!thread.isGroup() && senderId == viewerId)) { + subtitle = String.format("%s: %s", username != null ? username : "", message); + } else { + subtitle = message; + } + } + return subtitle; + } + + public static String getUsername(final List users, + final long userId, + final long viewerId, + final Resources resources) { + if (userId == viewerId) { + return resources.getString(R.string.you); + } + final Optional senderOptional = users.stream() + .filter(Objects::nonNull) + .filter(user -> user.getPk() == userId) + .findFirst(); + return senderOptional.map(user -> { + // return full name for fb users + final String username = user.getUsername(); + if (TextUtils.isEmpty(username)) { + return user.getFullName(); + } + return username; + }).orElse(null); + } + + public static String getMediaSpecificSubtitle(final String username, final Resources resources, final MediaItemType mediaType) { + final String userSharedAnImage = resources.getString(R.string.dms_inbox_shared_image, username != null ? username : ""); + final String userSharedAVideo = resources.getString(R.string.dms_inbox_shared_video, username != null ? username : ""); + final String userSentAMessage = resources.getString(R.string.dms_inbox_shared_message, username != null ? username : ""); + String subtitle; + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + subtitle = userSharedAnImage; + break; + case MEDIA_TYPE_VIDEO: + subtitle = userSharedAVideo; + break; + default: + subtitle = userSentAMessage; + break; + } + return subtitle; + } + + private static String getRavenMediaSubtitle(final DirectItem item, + final Resources resources, + final String username) { + String subtitle = "↗ "; + final DirectItemVisualMedia visualMedia = item.getVisualMedia(); + final RavenExpiringMediaActionSummary summary = visualMedia.getExpiringMediaActionSummary(); + if (summary != null) { + final ActionType expiringMediaType = summary.getType(); + int textRes = 0; + switch (expiringMediaType) { + case DELIVERED: + textRes = R.string.dms_inbox_raven_media_delivered; + break; + case SENT: + textRes = R.string.dms_inbox_raven_media_sent; + break; + case OPENED: + textRes = R.string.dms_inbox_raven_media_opened; + break; + case REPLAYED: + textRes = R.string.dms_inbox_raven_media_replayed; + break; + case SENDING: + textRes = R.string.dms_inbox_raven_media_sending; + break; + case BLOCKED: + textRes = R.string.dms_inbox_raven_media_blocked; + break; + case SUGGESTED: + textRes = R.string.dms_inbox_raven_media_suggested; + break; + case SCREENSHOT: + textRes = R.string.dms_inbox_raven_media_screenshot; + break; + case CANNOT_DELIVER: + textRes = R.string.dms_inbox_raven_media_cant_deliver; + break; + } + if (textRes > 0) { + subtitle += resources.getString(textRes); + } + return subtitle; + } + final MediaItemType mediaType = visualMedia.getMedia().getType(); + subtitle = getMediaSpecificSubtitle(username, resources, mediaType); + return subtitle; + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/DateUtils.kt b/app/src/main/java/awais/instagrabber/utils/DateUtils.kt new file mode 100644 index 0000000..299c1f7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/DateUtils.kt @@ -0,0 +1,29 @@ +package awais.instagrabber.utils + +import android.util.Log +import awais.instagrabber.utils.extensions.TAG +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.* + +object DateUtils { + val timezoneOffset: Int + get() { + val calendar = Calendar.getInstance(Locale.getDefault()) + return -(calendar[Calendar.ZONE_OFFSET] + calendar[Calendar.DST_OFFSET]) / (60 * 1000) + } + + @JvmStatic + fun isBeforeOrEqual(localDateTime: LocalDateTime, comparedTo: LocalDateTime): Boolean { + return localDateTime.isBefore(comparedTo) || localDateTime.isEqual(comparedTo) + } + + @JvmStatic + fun checkFormatterValid(datetimeParser: DateTimeFormatter): Boolean = try { + LocalDateTime.now().format(datetimeParser) + true + } catch (e: Exception) { + Log.e(TAG, "checkFormatterValid: ", e) + false + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/Debouncer.java b/app/src/main/java/awais/instagrabber/utils/Debouncer.java new file mode 100644 index 0000000..adecc66 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/Debouncer.java @@ -0,0 +1,91 @@ +package awais.instagrabber.utils; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +// TODO replace with kotlinx-coroutines debounce +public class Debouncer { + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final ConcurrentHashMap delayedMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> futureMap = new ConcurrentHashMap<>(); + private final Callback callback; + private final int interval; + + public Debouncer(Callback c, int interval) { + this.callback = c; + this.interval = interval; + } + + public void call(T key) { + TimerTask task = new TimerTask(key); + + TimerTask prev; + do { + prev = delayedMap.putIfAbsent(key, task); + if (prev == null) { + final ScheduledFuture future = scheduler.schedule(task, interval, TimeUnit.MILLISECONDS); + futureMap.put(key, future); + } + } while (prev != null && !prev.extend()); // Exit only if new task was added to map, or existing task was extended successfully + } + + public void terminate() { + scheduler.shutdownNow(); + } + + public void cancel(final T key) { + delayedMap.remove(key); + final ScheduledFuture future = futureMap.get(key); + if (future != null) { + future.cancel(true); + } + } + + // The task that wakes up when the wait time elapses + private class TimerTask implements Runnable { + private final T key; + private long dueTime; + private final Object lock = new Object(); + + public TimerTask(T key) { + this.key = key; + extend(); + } + + public boolean extend() { + synchronized (lock) { + if (dueTime < 0) // Task has been shutdown + return false; + dueTime = System.currentTimeMillis() + interval; + return true; + } + } + + public void run() { + synchronized (lock) { + long remaining = dueTime - System.currentTimeMillis(); + if (remaining > 0) { // Re-schedule task + scheduler.schedule(this, remaining, TimeUnit.MILLISECONDS); + } else { // Mark as terminated and invoke callback + dueTime = -1; + try { + callback.call(key); + } catch (Exception e) { + callback.onError(e); + } finally { + delayedMap.remove(key); + } + } + } + } + } + + public interface Callback { + void call(T key); + + void onError(Throwable t); + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/DeepLinkParser.kt b/app/src/main/java/awais/instagrabber/utils/DeepLinkParser.kt new file mode 100644 index 0000000..7dee2dd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/DeepLinkParser.kt @@ -0,0 +1,29 @@ +package awais.instagrabber.utils + +import java.util.regex.Pattern + +object DeepLinkParser { + private val TYPE_PATTERN_MAP: Map = mapOf( + DeepLink.Type.USER to DeepLinkPattern("instagram://user?username="), + ) + + @JvmStatic + fun parse(text: String): DeepLink? { + for ((key, value) in TYPE_PATTERN_MAP) { + if (text.startsWith(value.patternText)) { + return DeepLink(key, value.pattern.matcher(text).replaceAll("")) + } + } + return null + } + + data class DeepLinkPattern(val patternText: String) { + val pattern: Pattern = Pattern.compile(patternText, Pattern.LITERAL) + } + + data class DeepLink(val type: Type, val value: String) { + enum class Type { + USER + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.kt b/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.kt new file mode 100644 index 0000000..24a1096 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.kt @@ -0,0 +1,124 @@ +@file:JvmName("DirectItemFactory") + +package awais.instagrabber.utils + +import android.net.Uri +import awais.instagrabber.models.enums.DirectItemType +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.repositories.responses.* +import awais.instagrabber.repositories.responses.directmessages.DirectItem +import awais.instagrabber.repositories.responses.directmessages.DirectItemAnimatedMedia +import awais.instagrabber.repositories.responses.directmessages.DirectItemVoiceMedia +import awais.instagrabber.repositories.responses.giphy.GiphyGif +import java.util.* + +fun createText( + userId: Long, + clientContext: String?, + text: String?, + repliedToMessage: DirectItem? +): DirectItem { + return DirectItem( + itemId = UUID.randomUUID().toString(), + userId = userId, + timestamp = System.currentTimeMillis() * 1000, + itemType = DirectItemType.TEXT, + text = text, + clientContext = clientContext, + repliedToMessage = repliedToMessage, + ) +} + +fun createImageOrVideo( + userId: Long, + clientContext: String?, + uri: Uri, + width: Int, + height: Int, + isVideo: Boolean +): DirectItem { + val imageVersions2 = ImageVersions2(listOf(MediaCandidate(width, height, uri.toString()))) + var videoVersions: List? = null + if (isVideo) { + val videoVersion = MediaCandidate( + width, + height, + uri.toString() + ) + videoVersions = listOf(videoVersion) + } + val media = Media( + id = UUID.randomUUID().toString(), + imageVersions2 = imageVersions2, + originalWidth = width, + originalHeight = height, + mediaType = if (isVideo) MediaItemType.MEDIA_TYPE_VIDEO.id else MediaItemType.MEDIA_TYPE_IMAGE.id, + videoVersions = videoVersions, + ) + return DirectItem( + itemId = UUID.randomUUID().toString(), + userId = userId, + timestamp = System.currentTimeMillis() * 1000, + itemType = DirectItemType.MEDIA, + clientContext = clientContext, + media = media, + ) +} + +fun createVoice( + userId: Long, + clientContext: String?, + uri: Uri, + duration: Long, + waveform: List?, + samplingFreq: Int +): DirectItem { + val audio = Audio( + uri.toString(), + duration, + waveform, + samplingFreq, + 0 + ) + val media = Media( + id = UUID.randomUUID().toString(), + mediaType = MediaItemType.MEDIA_TYPE_VOICE.id, + audio = audio, + ) + val voiceMedia = DirectItemVoiceMedia( + media, + 0, + "permanent" + ) + return DirectItem( + itemId = UUID.randomUUID().toString(), + userId = userId, + timestamp = System.currentTimeMillis() * 1000, + itemType = DirectItemType.VOICE_MEDIA, + clientContext = clientContext, + media = media, + voiceMedia = voiceMedia, + ) +} + +fun createAnimatedMedia( + userId: Long, + clientContext: String?, + giphyGif: GiphyGif +): DirectItem { + val animatedImages = AnimatedMediaImages(giphyGif.images.fixedHeight) + val animateMedia = DirectItemAnimatedMedia( + giphyGif.id, + animatedImages, + false, + giphyGif.isSticker + ) + return DirectItem( + itemId = UUID.randomUUID().toString(), + userId = userId, + timestamp = System.currentTimeMillis() * 1000, + itemType = DirectItemType.ANIMATED_MEDIA, + clientContext = clientContext, + animatedMedia = animateMedia, + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/DownloadUtils.kt b/app/src/main/java/awais/instagrabber/utils/DownloadUtils.kt new file mode 100644 index 0000000..d3021e3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/DownloadUtils.kt @@ -0,0 +1,571 @@ +package awais.instagrabber.utils + +import android.content.Context +import android.content.UriPermission +import android.net.Uri +import android.provider.DocumentsContract +import android.util.Log +import android.view.MenuItem +import android.view.View +import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.PopupMenu +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.documentfile.provider.DocumentFile +import androidx.work.* +import awais.instagrabber.R +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.stories.StoryMedia +import awais.instagrabber.utils.AppExecutors.tasksThread +import awais.instagrabber.utils.TextUtils.isEmpty +import awais.instagrabber.workers.DownloadWorker +import com.google.gson.Gson +import java.io.BufferedWriter +import java.io.IOException +import java.io.OutputStreamWriter +import java.util.* +import java.util.regex.Pattern +import kotlin.math.abs + + +object DownloadUtils { + private val TAG = DownloadUtils::class.java.simpleName + + // private static final String DIR_BARINSTA = "Barinsta"; + private const val DIR_DOWNLOADS = "Downloads" + private const val DIR_CAMERA = "Camera" + private const val DIR_EDIT = "Edit" + private const val DIR_RECORDINGS = "Sent Recordings" + private const val DIR_TEMP = "Temp" + private const val DIR_BACKUPS = "Backups" + private const val MIME_DIR = DocumentsContract.Document.MIME_TYPE_DIR + private val dirMap: MutableMap = mutableMapOf() + private var root: DocumentFile? = null + @JvmStatic + @Throws(ReselectDocumentTreeException::class) + fun init( + context: Context, + barinstaDirUri: String? + ) { + if (isEmpty(barinstaDirUri)) { + throw ReselectDocumentTreeException("folder path is null or empty") + } + val uri = Uri.parse(barinstaDirUri) + if (!barinstaDirUri!!.startsWith("content://com.android.externalstorage.documents")) { + throw ReselectDocumentTreeException(uri) + } + val existingPermissions = context.contentResolver.persistedUriPermissions + if (existingPermissions.isEmpty()) { + throw ReselectDocumentTreeException(uri) + } + val anyMatch = existingPermissions.stream() + .anyMatch { uriPermission: UriPermission -> uriPermission.uri == uri } + if (!anyMatch) { + throw ReselectDocumentTreeException(uri) + } + root = DocumentFile.fromTreeUri(context, uri) + if (root == null || !root!!.exists() || root!!.lastModified() == 0L) { + root = null + throw ReselectDocumentTreeException(uri) + } + Utils.settingsHelper.putString(PreferenceKeys.PREF_BARINSTA_DIR_URI, uri.toString()) + // set up directories + val dirKeys = mapOf( + DIR_DOWNLOADS to MIME_DIR, + DIR_CAMERA to MIME_DIR, + DIR_EDIT to MIME_DIR, + DIR_RECORDINGS to MIME_DIR, + DIR_TEMP to MIME_DIR, + DIR_BACKUPS to MIME_DIR + ) + dirMap.putAll(checkFiles(context, root, dirKeys, true)) + } + + fun destroy() { + root = null + dirMap.clear() + } + + fun checkFiles(context: Context, + parent: DocumentFile?, + queries: Map, // + create: Boolean + ): Map { + // first we'll find existing ones + val result: MutableMap = mutableMapOf() + if (root == null || parent == null || !parent.isDirectory) return result.toMap() + val docId = DocumentsContract.getDocumentId(parent.uri) + val docUri = DocumentsContract.buildChildDocumentsUriUsingTree(root!!.uri, docId) + val docCursor = context.contentResolver.query( + docUri, arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE + ), null, null, null + ) + if (docCursor == null) return result.toMap() + while (docCursor.moveToNext()) { + val q = queries.get(docCursor.getString(0)) + if (q == null || !docCursor.getString(2).equals(q)) continue + val fileUri = DocumentsContract.buildDocumentUriUsingTree(parent.uri, docCursor.getString(1)) + val dir = if (q.equals(MIME_DIR)) DocumentFile.fromTreeUri(context, fileUri) + else DocumentFile.fromSingleUri(context, fileUri) + result.put(docCursor.getString(0), dir) + if (result.size >= queries.size) break + } + docCursor.close() + // next we'll create inexistent ones, if necessary + if (create) { + for (k in queries) { + if (result.get(k.key) == null) { + result.put(k.key, if (MIME_DIR.equals(k.value)) parent.createDirectory(k.key) + else parent.createFile(k.value, k.key)) + } + } + } + return result.toMap() + } + + fun getRootDir(dir: String): DocumentFile? { + if (root == null) return null + return dirMap.get(dir) + } + + @JvmStatic + val downloadDir: DocumentFile? + get() = getRootDir(DIR_DOWNLOADS) + + @JvmStatic + val cameraDir: DocumentFile? + get() = getRootDir(DIR_CAMERA) + + @JvmStatic + fun getImageEditDir(sessionId: String?, context: Context): DocumentFile? { + val editRoot = getRootDir(DIR_EDIT) + if (sessionId == null) return editRoot + return checkFiles(context, + editRoot, + mapOf(sessionId to MIME_DIR), + true).get(sessionId) + } + + @JvmStatic + val recordingsDir: DocumentFile? + get() = getRootDir(DIR_RECORDINGS) + + @JvmStatic + val backupsDir: DocumentFile? + get() = getRootDir(DIR_BACKUPS) + + private fun getDownloadDir( + context: Context, + username: String?, + shouldCreate: Boolean + ): DocumentFile? { + if (!Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_USER_FOLDER) || username.isNullOrEmpty()) + return downloadDir + return checkFiles(context, + downloadDir, + mapOf(username to MIME_DIR), + shouldCreate).get(username) + } + + private val tempDir: DocumentFile? + get() = getRootDir(DIR_TEMP) + + private fun getDownloadSavePaths( + postId: String?, + displayUrl: String? + ): Pair { + return getDownloadFileName(postId, "", displayUrl, "") + } + + private fun getDownloadSavePaths( + postId: String?, + displayUrl: String, + username: String + ): Pair { + return getDownloadFileName(postId, "", displayUrl, username) + } + + private fun getDownloadChildSavePaths( + postId: String?, + childPosition: Int, + url: String?, + username: String + ): Pair { + val sliderPostfix = "_slide_$childPosition" + return getDownloadFileName(postId, sliderPostfix, url, username) + } + + private fun getDownloadFileName( + postId: String?, + sliderPostfix: String, + displayUrl: String?, + username: String + ): Pair { + val extension = getFileExtensionFromUrl(displayUrl) + val usernamePrepend = if (isEmpty(username)) "" else username + "_" + val fileName = usernamePrepend + postId + sliderPostfix + extension + val mimeType = Utils.mimeTypeMap.getMimeTypeFromExtension(extension) + return Pair(fileName, mimeType!!) + } + + fun getTempFile(fileName: String?, extension: String): DocumentFile? { + val dir = tempDir + var name = fileName + if (isEmpty(name)) { + name = UUID.randomUUID().toString() + } + var mimeType: String? = "application/octet-stream" + if (!isEmpty(extension)) { + name += ".$extension" + val mimeType1 = Utils.mimeTypeMap.getMimeTypeFromExtension(extension) + if (mimeType1 != null) { + mimeType = mimeType1 + } + } + var file = dir!!.findFile(name!!) + if (file == null) { + file = dir.createFile(mimeType!!, name) + } + return file + } + + /** + * Copied from [MimeTypeMap.getFileExtensionFromUrl]) + * + * + * Returns the file extension or an empty string if there is no + * extension. This method is a convenience method for obtaining the + * extension of a url and has undefined results for other Strings. + * + * @param url URL + * @return The file extension of the given url. + */ + @JvmStatic + fun getFileExtensionFromUrl(url: String?): String { + var url = url + if (!isEmpty(url)) { + val fragment = url!!.lastIndexOf('#') + if (fragment > 0) { + url = url.substring(0, fragment) + } + val query = url.lastIndexOf('?') + if (query > 0) { + url = url.substring(0, query) + } + val filenamePos = url.lastIndexOf('/') + val filename = if (0 <= filenamePos) url.substring(filenamePos + 1) else url + + // if the filename contains special characters, we don't + // consider it valid for our matching purposes: + if (!filename.isEmpty() && + Pattern.matches("[a-zA-Z_0-9.\\-()%]+", filename) + ) { + val dotPos = filename.lastIndexOf('.') + if (0 <= dotPos) { + return filename.substring(dotPos + 1) + } + } + } + return "" + } + + @JvmStatic + fun checkDownloaded(media: Media, context: Context): List { + val checkList: MutableList = LinkedList() + val user = media.user + var username = "username" + if (user != null) { + username = user.username + } + val userFolder = getDownloadDir(context, username, false) + if (userFolder == null) return checkList + when (media.type) { + MediaItemType.MEDIA_TYPE_IMAGE, MediaItemType.MEDIA_TYPE_VIDEO -> { + val url = + if (media.type == MediaItemType.MEDIA_TYPE_VIDEO) ResponseBodyUtils.getVideoUrl( + media + ) else ResponseBodyUtils.getImageUrl(media) + val fileName = getDownloadSavePaths(media.code, url) + val fileNameWithUser = getDownloadSavePaths(media.code, url, username) + val files = checkFiles(context, userFolder, mapOf(fileName, fileNameWithUser), false) + checkList.add(files.size > 0) + } + MediaItemType.MEDIA_TYPE_SLIDER -> { + val sliderItems = media.carouselMedia + val fileNames: MutableMap = mutableMapOf() + val filePairs: MutableMap = mutableMapOf() + var i = 0 + while (i < sliderItems!!.size) { + val child = sliderItems[i] + val url = + if (child.type == MediaItemType.MEDIA_TYPE_VIDEO) ResponseBodyUtils.getVideoUrl( + child + ) else ResponseBodyUtils.getImageUrl(child) + val fileName = getDownloadChildSavePaths(media.code, i+1, url, "") + val fileNameWithUser = getDownloadChildSavePaths(media.code, i+1, url, username) + fileNames.put(fileName.first, fileName.second) + fileNames.put(fileNameWithUser.first, fileNameWithUser.second) + filePairs.put(fileName.first, fileNameWithUser.first) + i++ + } + val files = checkFiles(context, userFolder, fileNames, false) + for (p in filePairs) { + checkList.add(files.get(p.key) != null || files.get(p.value) != null) + } + } + else -> { + } + } + return checkList + } + + @JvmStatic + fun showDownloadDialog( + context: Context, + feedModel: Media, + childPosition: Int, + popupLocation: View? + ) { + if (childPosition == -1 || popupLocation == null) { + download(context, feedModel) + return + } + val themeWrapper = ContextThemeWrapper(context, R.style.popupMenuStyle) + val popupMenu = PopupMenu(themeWrapper, popupLocation) + val menu = popupMenu.menu + menu.add(0, R.id.download_current, 0, R.string.post_viewer_download_current) + menu.add(0, R.id.download_all, 1, R.string.post_viewer_download_album) + popupMenu.setOnMenuItemClickListener { item: MenuItem -> + val itemId = item.itemId + if (itemId == R.id.download_current) { + download(context, feedModel, childPosition) + } else if (itemId == R.id.download_all) { + download(context, feedModel) + } + false + } + popupMenu.show() + } + + @JvmStatic + fun download( + context: Context, + storyModel: StoryMedia + ) { + val downloadDir = getDownloadDir(context, storyModel.user?.username, true) ?: return + val url = + if (storyModel.type == MediaItemType.MEDIA_TYPE_VIDEO) ResponseBodyUtils.getVideoUrl(storyModel) + else ResponseBodyUtils.getImageUrl(storyModel) + val extension = getFileExtensionFromUrl(url) + val mimeType = Utils.mimeTypeMap.getMimeTypeFromExtension(extension) + val baseFileName = storyModel.id + "_" + storyModel.takenAt + extension + val usernamePrepend = + if (Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME) + && storyModel.user?.username != null + ) storyModel.user.username + "_" else "" + val fileName = usernamePrepend + baseFileName + var saveFile = checkFiles(context, downloadDir, mapOf(fileName to mimeType!!), true).get(fileName) + download(context, url, saveFile) + } + + @JvmOverloads + @JvmStatic + fun download( + context: Context, + feedModel: Media, + position: Int = -1 + ) { + download(context, listOf(feedModel), position) + } + + // this must be used for bulk download, but ONLY bulk download + @JvmStatic + fun download( + context: Context, + feedModels: List + ) { + val builder = NotificationCompat.Builder(context, Constants.DOWNLOAD_CHANNEL_ID) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setSmallIcon(R.drawable.ic_download) + .setOngoing(true) + .setProgress(1, 0, true) + .setAutoCancel(false) + .setOnlyAlertOnce(true) + .setContentTitle(context.getString(R.string.downloader_preparing)) + val notification = builder.build() + val nid = abs(UUID.randomUUID().hashCode()) + val nManager = NotificationManagerCompat.from(context.applicationContext) + nManager.notify(nid, notification) + tasksThread.execute { + download(context, feedModels, -1) + nManager.cancel(nid) + } + } + + private fun download( + context: Context, + feedModels: List, + childPositionIfSingle: Int + ) { + val map: MutableMap> = HashMap() + val fileMap: MutableMap = HashMap() + for (media in feedModels) { + val mediaUser = media.user + val username = mediaUser?.username ?: "" + val dir = getDownloadDir(context, username, true) + when (media.type) { + MediaItemType.MEDIA_TYPE_IMAGE, MediaItemType.MEDIA_TYPE_VIDEO -> { + val url = getUrlOfType(media) + var fileName = media.id + if (mediaUser != null && isEmpty(media.code)) { + fileName = mediaUser.username + "_" + fileName + } + if (!isEmpty(media.code)) { + fileName = media.code + if (Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME) && mediaUser != null) { + fileName = mediaUser.username + "_" + fileName + } + } + val pair = getDownloadSavePaths(fileName, url) + map[url!!] = pair + } + MediaItemType.MEDIA_TYPE_VOICE -> { + val url = getUrlOfType(media) + var fileName = media.id + if (mediaUser != null) { + fileName = mediaUser.username + "_" + fileName + } + val pair = getDownloadSavePaths(fileName, url) + map[url!!] = pair + } + MediaItemType.MEDIA_TYPE_SLIDER -> { + val sliderItems = media.carouselMedia + var i = 0 + while (i < sliderItems!!.size) { + if (childPositionIfSingle >= 0 && feedModels.size == 1 && i != childPositionIfSingle) { + i++ + continue + } + val child = sliderItems[i] + val url = getUrlOfType(child) + val usernamePrepend = + if (Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME) && mediaUser != null) mediaUser.username else "" + val pair = getDownloadChildSavePaths(media.code, i + 1, url, usernamePrepend) + map[url!!] = pair + i++ + } + } + } + fileMap.putAll(checkFiles(context, dir, map.values.toMap(), true)) + } + if (map.isEmpty() || fileMap.isEmpty()) return + val resultMap: MutableMap = mutableMapOf() + map.mapValuesTo(resultMap) { fileMap.get(it.value.first) } + download(context, resultMap) + } + + private fun getUrlOfType(media: Media): String? { + when (media.type) { + MediaItemType.MEDIA_TYPE_IMAGE -> { + return ResponseBodyUtils.getImageUrl(media) + } + MediaItemType.MEDIA_TYPE_VIDEO -> { + val videoVersions = media.videoVersions + var url: String? = null + if (videoVersions != null && !videoVersions.isEmpty()) { + url = videoVersions[0].url + } + return url + } + MediaItemType.MEDIA_TYPE_VOICE -> { + val audio = media.audio + var url: String? = null + if (audio != null) { + url = audio.audioSrc + } + return url + } + } + return null + } + + @JvmStatic + fun download( + context: Context?, + url: String?, + filePath: DocumentFile? + ) { + if (context == null || filePath == null) return + download(context, Collections.singletonMap(url!!, filePath)) + } + + private fun download(context: Context?, urlFilePathMap: Map) { + if (context == null) return + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = DownloadWorker.DownloadRequest.builder() + .setUrlToFilePathMap(urlFilePathMap) + .build() + val requestJson = Gson().toJson(request) + val tempFile = getTempFile(null, "json") + if (tempFile == null) { + Log.e(TAG, "download: temp file is null") + return + } + val uri = tempFile.uri + val contentResolver = context.contentResolver ?: return + try { + BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use { writer -> + writer.write( + requestJson + ) + } + } catch (e: IOException) { + Log.e(TAG, "download: Error writing request to file", e) + tempFile.delete() + return + } + val downloadWorkRequest: WorkRequest = + OneTimeWorkRequest.Builder(DownloadWorker::class.java) + .setInputData( + Data.Builder() + .putString( + DownloadWorker.KEY_DOWNLOAD_REQUEST_JSON, + tempFile.uri.toString() + ) + .build() + ) + .setConstraints(constraints) + .addTag("download") + .build() + WorkManager.getInstance(context) + .enqueue(downloadWorkRequest) + } + + @JvmStatic + fun getRootDirUri(): Uri? { + return if (root != null) root!!.uri else null + } + + class ReselectDocumentTreeException : Exception { + val initialUri: Uri? + + constructor() { + initialUri = null + } + + constructor(message: String?) : super(message) { + initialUri = null + } + + constructor(initialUri: Uri?) { + this.initialUri = initialUri + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/Event.kt b/app/src/main/java/awais/instagrabber/utils/Event.kt new file mode 100644 index 0000000..fea0d4f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/Event.kt @@ -0,0 +1,27 @@ +package awais.instagrabber.utils + +/** + * Used as a wrapper for data that is exposed via a LiveData that represents an event. + */ +open class Event(private val content: T) { + + var hasBeenHandled = false + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/ExoplayerUtils.kt b/app/src/main/java/awais/instagrabber/utils/ExoplayerUtils.kt new file mode 100644 index 0000000..cc09440 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ExoplayerUtils.kt @@ -0,0 +1,22 @@ +package awais.instagrabber.utils + +import android.content.Context +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache + +object ExoplayerUtils { + private const val MAX_CACHE_BYTES: Long = 1048576 + private val cacheEvictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES) + fun getCachedMediaSourceFactory(context: Context): DefaultMediaSourceFactory { + val exoDatabaseProvider = ExoDatabaseProvider(context) + val simpleCache = SimpleCache(context.cacheDir, cacheEvictor, exoDatabaseProvider) + val cacheDataSourceFactory = CacheDataSource.Factory() + .setCache(simpleCache) + .setUpstreamDataSourceFactory(DefaultHttpDataSourceFactory()) + return DefaultMediaSourceFactory(cacheDataSourceFactory) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/ExportImportUtils.java b/app/src/main/java/awais/instagrabber/utils/ExportImportUtils.java new file mode 100755 index 0000000..64d91cd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ExportImportUtils.java @@ -0,0 +1,443 @@ +package awais.instagrabber.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.util.Base64; +import android.util.Log; +import android.util.Pair; +import android.widget.Toast; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import awais.instagrabber.BuildConfig; +import awais.instagrabber.db.entities.Account; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.repositories.AccountRepository; +import awais.instagrabber.db.repositories.FavoriteRepository; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.utils.PasswordUtils.IncorrectPasswordException; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +//import awaisomereport.LogCollector.LogFile; +//import static awais.instagrabber.utils.Utils.logCollector; + +public final class ExportImportUtils { + private static final String TAG = "ExportImportUtils"; + + public static final int FLAG_COOKIES = 1; + public static final int FLAG_FAVORITES = 1 << 1; + public static final int FLAG_SETTINGS = 1 << 2; + + public static void importData(@NonNull final Context context, + @ExportImportFlags final int flags, + @NonNull final Uri uri, + final String password, + final FetchListener fetchListener) throws IncorrectPasswordException { + try (final InputStream stream = context.getContentResolver().openInputStream(uri)) { + if (stream == null) return; + final int configType = stream.read(); + final StringBuilder builder = new StringBuilder(); + int c; + while ((c = stream.read()) != -1) { + builder.append((char) c); + } + if (configType == 'A') { + // password + if (TextUtils.isEmpty(password)) return; + try { + final byte[] passwordBytes = password.getBytes(); + final byte[] bytes = new byte[32]; + System.arraycopy(passwordBytes, 0, bytes, 0, Math.min(passwordBytes.length, 32)); + importJson(context, + new String(PasswordUtils.dec(builder.toString(), bytes)), + flags, + fetchListener); + } catch (final IncorrectPasswordException e) { + throw e; + } catch (final Exception e) { + if (fetchListener != null) fetchListener.onResult(false); + if (BuildConfig.DEBUG) Log.e(TAG, "Error importing backup", e); + } + } else if (configType == 'Z') { + importJson(context, + new String(Base64.decode(builder.toString(), Base64.DEFAULT | Base64.NO_PADDING | Base64.NO_WRAP)), + flags, + fetchListener); + + } else { + Toast.makeText(context, "File is corrupted!", Toast.LENGTH_LONG).show(); + if (fetchListener != null) fetchListener.onResult(false); + } + } catch (IncorrectPasswordException e) { + // separately handle incorrect password + throw e; + } catch (final Exception e) { + if (fetchListener != null) fetchListener.onResult(false); + if (BuildConfig.DEBUG) Log.e(TAG, "", e); + } + } + + private static void importJson(final Context context, + @NonNull final String json, + @ExportImportFlags final int flags, + final FetchListener fetchListener) { + try { + final JSONObject jsonObject = new JSONObject(json); + if ((flags & FLAG_SETTINGS) == FLAG_SETTINGS && jsonObject.has("settings")) { + importSettings(jsonObject); + } + if ((flags & FLAG_COOKIES) == FLAG_COOKIES && jsonObject.has("cookies")) { + importAccounts(context, jsonObject); + } + if ((flags & FLAG_FAVORITES) == FLAG_FAVORITES && jsonObject.has("favs")) { + importFavorites(context, jsonObject); + } + if (fetchListener != null) fetchListener.onResult(true); + } catch (final Exception e) { + if (fetchListener != null) fetchListener.onResult(false); + if (BuildConfig.DEBUG) Log.e(TAG, "", e); + } + } + + private static void importFavorites(final Context context, final JSONObject jsonObject) throws JSONException { + final JSONArray favs = jsonObject.getJSONArray("favs"); + for (int i = 0; i < favs.length(); i++) { + final JSONObject favsObject = favs.getJSONObject(i); + final String queryText = favsObject.optString("q"); + if (TextUtils.isEmpty(queryText)) continue; + final Pair favoriteTypeQueryPair; + String query = null; + FavoriteType favoriteType = null; + if (queryText.contains("@") + || queryText.contains("#") + || queryText.contains("/")) { + favoriteTypeQueryPair = Utils.migrateOldFavQuery(queryText); + if (favoriteTypeQueryPair != null) { + query = favoriteTypeQueryPair.second; + favoriteType = favoriteTypeQueryPair.first; + } + } else { + query = queryText; + favoriteType = FavoriteType.valueOf(favsObject.optString("type")); + } + if (query == null || favoriteType == null) { + continue; + } + final long epochMillis = favsObject.getLong("d"); + final Favorite favorite = new Favorite( + 0, + query, + favoriteType, + favsObject.optString("s"), + favoriteType == FavoriteType.USER ? favsObject.optString("pic_url") : null, + LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()) + ); + // Log.d(TAG, "importJson: favoriteModel: " + favoriteModel); + final FavoriteRepository favRepo = FavoriteRepository.Companion.getInstance(context); + favRepo.getFavorite( + query, + favoriteType, + CoroutineUtilsKt.getContinuation((favorite1, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "importFavorites: ", throwable); + return; + } + if (favorite1 == null) { + favRepo.insertOrUpdateFavorite(favorite, CoroutineUtilsKt.getContinuation((unit, throwable1) -> {}, Dispatchers.getIO())); + } + // local has priority since it's more frequently updated + }), Dispatchers.getIO()) + ); + } + } + + private static void importAccounts(final Context context, + final JSONObject jsonObject) { + final List accounts = new ArrayList<>(); + try { + final JSONArray cookies = jsonObject.getJSONArray("cookies"); + for (int i = 0; i < cookies.length(); i++) { + final JSONObject cookieObject = cookies.getJSONObject(i); + final Account account = new Account( + -1, + cookieObject.optString("i"), + cookieObject.optString("u"), + cookieObject.optString("c"), + cookieObject.optString("full_name"), + cookieObject.optString("profile_pic") + ); + if (!account.isValid()) continue; + accounts.add(account); + } + } catch (Exception e) { + Log.e(TAG, "importAccounts: Error parsing json", e); + return; + } + AccountRepository.Companion + .getInstance(context) + .insertOrUpdateAccounts(accounts, CoroutineUtilsKt.getContinuation((unit, throwable) -> {}, Dispatchers.getIO())); + } + + private static void importSettings(final JSONObject jsonObject) { + try { + final JSONObject objSettings = jsonObject.getJSONObject("settings"); + final Iterator keys = objSettings.keys(); + while (keys.hasNext()) { + final String key = keys.next(); + final Object val = objSettings.opt(key); + // Log.d(TAG, "importJson: key: " + key + ", val: " + val); + if (val instanceof String) { + settingsHelper.putString(key, (String) val); + } else if (val instanceof Integer) { + settingsHelper.putInteger(key, (int) val); + } else if (val instanceof Boolean) { + settingsHelper.putBoolean(key, (boolean) val); + } + } + } catch (Exception e) { + Log.e(TAG, "importSettings error", e); + } + } + + public static boolean isEncrypted(@NonNull final Context context, + @NonNull final Uri uri) { + try (final InputStream stream = context.getContentResolver().openInputStream(uri)) { + if (stream == null) return false; + final int configType = stream.read(); + if (configType == 'A') { + return true; + } + } catch (final Exception e) { + Log.e(TAG, "isEncrypted", e); + } + return false; + } + + public static void exportData(@NonNull final Context context, + @ExportImportFlags final int flags, + @NonNull final Uri uri, + final String password, + final FetchListener fetchListener) { + getExportString(flags, context, exportString -> { + if (TextUtils.isEmpty(exportString)) return; + final boolean isPass = !TextUtils.isEmpty(password); + byte[] exportBytes = null; + if (isPass) { + final byte[] passwordBytes = password.getBytes(); + final byte[] bytes = new byte[32]; + System.arraycopy(passwordBytes, 0, bytes, 0, Math.min(passwordBytes.length, 32)); + try { + exportBytes = PasswordUtils.enc(exportString, bytes); + } catch (final Exception e) { + if (fetchListener != null) fetchListener.onResult(false); + if (BuildConfig.DEBUG) Log.e(TAG, "", e); + } + } else { + exportBytes = Base64.encode(exportString.getBytes(), Base64.DEFAULT | Base64.NO_WRAP | Base64.NO_PADDING); + } + if (exportBytes != null && exportBytes.length > 1) { + try (final OutputStream stream = context.getContentResolver().openOutputStream(uri)) { + if (stream == null) return; + stream.write(isPass ? 'A' : 'Z'); + stream.write(exportBytes); + if (fetchListener != null) fetchListener.onResult(true); + } catch (Exception e) { + if (fetchListener != null) fetchListener.onResult(false); + if (BuildConfig.DEBUG) Log.e(TAG, "", e); + } + return; + } + if (fetchListener != null) { + fetchListener.onResult(false); + } + }); + + } + + private static void getExportString(@ExportImportFlags final int flags, + @NonNull final Context context, + final OnExportStringCreatedCallback callback) { + if (callback == null) return; + try { + final ImmutableList.Builder> futures = ImmutableList.builder(); + futures.add((flags & FLAG_SETTINGS) == FLAG_SETTINGS + ? getSettings(context) + : Futures.immediateFuture(null)); + futures.add((flags & FLAG_COOKIES) == FLAG_COOKIES + ? getCookies(context) + : Futures.immediateFuture(null)); + futures.add((flags & FLAG_FAVORITES) == FLAG_FAVORITES + ? getFavorites(context) + : Futures.immediateFuture(null)); + //noinspection UnstableApiUsage + final ListenableFuture> allFutures = Futures.allAsList(futures.build()); + Futures.addCallback(allFutures, new FutureCallback>() { + @Override + public void onSuccess(final List result) { + final JSONObject jsonObject = new JSONObject(); + if (result == null) { + callback.onCreated(jsonObject.toString()); + return; + } + try { + final JSONObject settings = (JSONObject) result.get(0); + if (settings != null) { + jsonObject.put("settings", settings); + } + } catch (Exception e) { + Log.e(TAG, "error getting settings: ", e); + } + try { + final JSONArray accounts = (JSONArray) result.get(1); + if (accounts != null) { + jsonObject.put("cookies", accounts); + } + } catch (Exception e) { + Log.e(TAG, "error getting accounts", e); + } + try { + final JSONArray favorites = (JSONArray) result.get(2); + if (favorites != null) { + jsonObject.put("favs", favorites); + } + } catch (Exception e) { + Log.e(TAG, "error getting favorites: ", e); + } + callback.onCreated(jsonObject.toString()); + } + + @Override + public void onFailure(@NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + callback.onCreated(null); + } + }, AppExecutors.INSTANCE.getTasksThread()); + return; + } catch (final Exception e) { + // if (logCollector != null) logCollector.appendException(e, LogFile.UTILS_EXPORT, "getExportString"); + if (BuildConfig.DEBUG) Log.e(TAG, "", e); + } + callback.onCreated(null); + } + + @NonNull + private static ListenableFuture getSettings(@NonNull final Context context) { + final SharedPreferences sharedPreferences = context.getSharedPreferences(Constants.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); + return AppExecutors.INSTANCE.getTasksThread().submit(() -> { + final Map allPrefs = sharedPreferences.getAll(); + if (allPrefs == null) { + return new JSONObject(); + } + try { + final JSONObject jsonObject = new JSONObject(allPrefs); + jsonObject.remove(Constants.COOKIE); + jsonObject.remove(Constants.DEVICE_UUID); + jsonObject.remove(Constants.PREV_INSTALL_VERSION); + jsonObject.remove(Constants.BROWSER_UA_CODE); + jsonObject.remove(Constants.BROWSER_UA); + jsonObject.remove(Constants.APP_UA_CODE); + jsonObject.remove(Constants.APP_UA); + return jsonObject; + } catch (Exception e) { + Log.e(TAG, "Error exporting settings", e); + } + return new JSONObject(); + }); + } + + private static ListenableFuture getFavorites(final Context context) { + final SettableFuture future = SettableFuture.create(); + final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(context); + favoriteRepository.getAllFavorites( + CoroutineUtilsKt.getContinuation((favorites, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + future.set(new JSONArray()); + Log.e(TAG, "getFavorites: ", throwable); + return; + } + final JSONArray jsonArray = new JSONArray(); + try { + for (final Favorite favorite : favorites) { + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("q", favorite.getQuery()); + jsonObject.put("type", favorite.getType().toString()); + jsonObject.put("s", favorite.getDisplayName()); + jsonObject.put("pic_url", favorite.getPicUrl()); + jsonObject.put("d", favorite.getDateAdded().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + jsonArray.put(jsonObject); + } + } catch (Exception e) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Error exporting favorites", e); + } + } + future.set(jsonArray); + }), Dispatchers.getIO()) + ); + return future; + } + + private static ListenableFuture getCookies(final Context context) { + final SettableFuture future = SettableFuture.create(); + final AccountRepository accountRepository = AccountRepository.Companion.getInstance(context); + accountRepository.getAllAccounts( + CoroutineUtilsKt.getContinuation((accounts, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "getCookies: ", throwable); + future.set(new JSONArray()); + return; + } + final JSONArray jsonArray = new JSONArray(); + try { + for (final Account cookie : accounts) { + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("i", cookie.getUid()); + jsonObject.put("u", cookie.getUsername()); + jsonObject.put("c", cookie.getCookie()); + jsonObject.put("full_name", cookie.getFullName()); + jsonObject.put("profile_pic", cookie.getProfilePic()); + jsonArray.put(jsonObject); + } + } catch (Exception e) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Error exporting accounts", e); + } + } + future.set(jsonArray); + }), Dispatchers.getIO()) + ); + return future; + } + + @IntDef(value = {FLAG_COOKIES, FLAG_FAVORITES, FLAG_SETTINGS}, flag = true) + @interface ExportImportFlags {} + + public interface OnExportStringCreatedCallback { + void onCreated(String exportString); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/FlavorTown.java b/app/src/main/java/awais/instagrabber/utils/FlavorTown.java new file mode 100755 index 0000000..006b7f9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/FlavorTown.java @@ -0,0 +1,93 @@ +package awais.instagrabber.utils; + +import android.content.Context; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import awais.instagrabber.BuildConfig; +import awais.instagrabber.R; +import awaisomereport.CrashReporterHelper; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public final class FlavorTown { + private static final String TAG = "FlavorTown"; + private static final UpdateChecker UPDATE_CHECKER = UpdateChecker.getInstance(); + private static final Pattern VERSION_NAME_PATTERN = Pattern.compile("v?(\\d+\\.\\d+\\.\\d+)(?:_?)(\\w*)(?:-?)(\\w*)"); + + private static boolean checking = false; + + public static void updateCheck(@NonNull final AppCompatActivity context) { + updateCheck(context, false); + } + + public static void updateCheck(@NonNull final AppCompatActivity context, + final boolean force) { + if (checking) return; + checking = true; + AppExecutors.INSTANCE.getNetworkIO().execute(() -> { + final String onlineVersionName = UPDATE_CHECKER.getLatestVersion(); + if (onlineVersionName == null) return; + final String onlineVersion = getVersion(onlineVersionName); + final String localVersion = getVersion(BuildConfig.VERSION_NAME); + if (Objects.equals(onlineVersion, localVersion)) { + if (force) { + AppExecutors.INSTANCE.getMainThread().execute(() -> { + final Context applicationContext = context.getApplicationContext(); + // Check if app was closed or crashed before reaching here + if (applicationContext == null) return; + // Show toast if version number preference was tapped + Toast.makeText(applicationContext, R.string.on_latest_version, Toast.LENGTH_SHORT).show(); + }); + } + return; + } + final boolean shouldShowDialog = UpdateCheckCommon.shouldShowUpdateDialog(force, onlineVersionName); + if (!shouldShowDialog) return; + UpdateCheckCommon.showUpdateDialog(context, onlineVersionName, (dialog, which) -> { + UPDATE_CHECKER.onDownload(context); + dialog.dismiss(); + }); + }); + } + + private static String getVersion(@NonNull final String versionName) { + final Matcher matcher = VERSION_NAME_PATTERN.matcher(versionName); + if (!matcher.matches()) return versionName; + try { + return matcher.group(1); + } catch (Exception e) { + Log.e(TAG, "getVersion: ", e); + } + return versionName; + } + + public static void changelogCheck(@NonNull final Context context) { + if (settingsHelper.getInteger(Constants.PREV_INSTALL_VERSION) >= BuildConfig.VERSION_CODE) return; + int appUaCode = settingsHelper.getInteger(Constants.APP_UA_CODE); + int browserUaCode = settingsHelper.getInteger(Constants.BROWSER_UA_CODE); + if (browserUaCode == -1 || browserUaCode >= UserAgentUtils.browsers.length) { + browserUaCode = ThreadLocalRandom.current().nextInt(0, UserAgentUtils.browsers.length); + settingsHelper.putInteger(Constants.BROWSER_UA_CODE, browserUaCode); + } + if (appUaCode == -1 || appUaCode >= UserAgentUtils.devices.length) { + appUaCode = ThreadLocalRandom.current().nextInt(0, UserAgentUtils.devices.length); + settingsHelper.putInteger(Constants.APP_UA_CODE, appUaCode); + } + final String appUa = UserAgentUtils.generateAppUA(appUaCode, LocaleUtils.getCurrentLocale().getLanguage()); + settingsHelper.putString(Constants.APP_UA, appUa); + final String browserUa = UserAgentUtils.generateBrowserUA(browserUaCode); + settingsHelper.putString(Constants.BROWSER_UA, browserUa); + AppExecutors.INSTANCE.getDiskIO().execute(() -> CrashReporterHelper.deleteAllStacktraceFiles(context)); + Toast.makeText(context, R.string.updated, Toast.LENGTH_SHORT).show(); + settingsHelper.putInteger(Constants.PREV_INSTALL_VERSION, BuildConfig.VERSION_CODE); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/IntentUtils.kt b/app/src/main/java/awais/instagrabber/utils/IntentUtils.kt new file mode 100644 index 0000000..884e608 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/IntentUtils.kt @@ -0,0 +1,46 @@ +package awais.instagrabber.utils + +import android.net.Uri +import android.text.TextUtils +import awais.instagrabber.models.IntentModel +import awais.instagrabber.models.enums.IntentModelType + +object IntentUtils { + @JvmStatic + fun parseUrl(url: String): IntentModel? { + val parsedUrl = Uri.parse(url).normalizeScheme() + + // final String domain = parsedUrl.getHost().replaceFirst("^www\\.", ""); + // final boolean isHttpsUri = "https".equals(parsedUrl.getScheme()); + val paths = parsedUrl.pathSegments + if (paths.isEmpty()) { + return null + } + var path = paths[0] + var text: String? = null + var type = IntentModelType.UNKNOWN + if (1 == paths.size) { + text = path + type = IntentModelType.USERNAME + } else if ("_u" == path) { + text = paths[1] + type = IntentModelType.USERNAME + } else if ("p" == path || "reel" == path || "tv" == path) { + text = paths[1] + type = IntentModelType.POST + } else if (2 < paths.size && "explore" == path) { + path = paths[1] + if ("locations" == path) { + text = paths[2] + type = IntentModelType.LOCATION + } + if ("tags" == path) { + text = paths[2] + type = IntentModelType.HASHTAG + } + } + return if (TextUtils.isEmpty(text)) { + null + } else IntentModel(type, text!!) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/KeywordsFilterUtils.kt b/app/src/main/java/awais/instagrabber/utils/KeywordsFilterUtils.kt new file mode 100644 index 0000000..355144d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/KeywordsFilterUtils.kt @@ -0,0 +1,30 @@ +package awais.instagrabber.utils + +import awais.instagrabber.repositories.responses.Media +import java.util.* +import kotlin.collections.ArrayList + +// fun filter(caption: String?): Boolean { +// if (caption == null) return false +// if (keywords.isEmpty()) return false +// val temp = caption.toLowerCase() +// for (s in keywords) { +// if (temp.contains(s)) return true +// } +// return false +// } + +private fun containsAnyKeyword(keywords: List, media: Media?): Boolean { + if (media == null || keywords.isEmpty()) return false + val (_, text) = media.caption ?: return false + val temp = text!!.lowercase(Locale.getDefault()) + return keywords.any { temp.contains(it) } +} + +fun filter(keywords: List, media: List?): List? { + if (keywords.isEmpty()) return media + if (media == null) return ArrayList() + val result: MutableList = ArrayList() + media.filterNotTo(result) { containsAnyKeyword(keywords, it) } + return result +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/LocaleUtils.kt b/app/src/main/java/awais/instagrabber/utils/LocaleUtils.kt new file mode 100755 index 0000000..b6e3df4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/LocaleUtils.kt @@ -0,0 +1,84 @@ +package awais.instagrabber.utils + +import android.content.Context +import android.content.res.Configuration +import android.view.ContextThemeWrapper +import awais.instagrabber.fragments.settings.PreferenceKeys +import java.util.* + +// taken from my app TESV Console Codes +object LocaleUtils { + private lateinit var defaultLocale: Locale + + @JvmStatic + lateinit var currentLocale: Locale + private set + + @JvmStatic + fun setLocale(baseContext: Context) { + var baseContext1 = baseContext + defaultLocale = Locale.getDefault() + if (baseContext1 is ContextThemeWrapper) baseContext1 = baseContext1.baseContext + if (Utils.settingsHelper == null) Utils.settingsHelper = SettingsHelper(baseContext1) + val appLanguageSettings = Utils.settingsHelper.getString(PreferenceKeys.APP_LANGUAGE) + val lang = getCorrespondingLanguageCode(appLanguageSettings) + currentLocale = when { + TextUtils.isEmpty(lang) -> defaultLocale + lang!!.contains("_") -> { + val split = lang.split("_") + Locale(split[0], split[1]) + } + else -> Locale(lang) + } + currentLocale.let { + Locale.setDefault(it) + val res = baseContext1.resources + val config = res.configuration + // config.locale = currentLocale + config.setLocale(it) + config.setLayoutDirection(it) + res.updateConfiguration(config, res.displayMetrics) + } + } + + @JvmStatic + fun updateConfig(wrapper: ContextThemeWrapper) { + if (!this::currentLocale.isInitialized) return + val configuration = Configuration() + // configuration.locale = currentLocale + configuration.setLocale(currentLocale) + wrapper.applyOverrideConfiguration(configuration) + } + + fun getCorrespondingLanguageCode(appLanguageSettings: String): String? { + if (TextUtils.isEmpty(appLanguageSettings)) return null + when (appLanguageSettings.toInt()) { + 1 -> return "en" + 2 -> return "fr" + 3 -> return "es" + 4 -> return "zh_CN" + 5 -> return "in" + 6 -> return "it" + 7 -> return "de" + 8 -> return "pl" + 9 -> return "tr" + 10 -> return "pt" + 11 -> return "fa" + 12 -> return "mk" + 13 -> return "vi" + 14 -> return "zh_TW" + 15 -> return "ca" + 16 -> return "ru" + 17 -> return "hi" + 18 -> return "nl" + 19 -> return "sk" + 20 -> return "ja" + 21 -> return "el" + 22 -> return "eu" + 23 -> return "sv" + 24 -> return "ko" + 25 -> return "ar" + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/MediaUploadHelper.kt b/app/src/main/java/awais/instagrabber/utils/MediaUploadHelper.kt new file mode 100644 index 0000000..bca3e36 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/MediaUploadHelper.kt @@ -0,0 +1,153 @@ +@file:JvmName("MediaUploadHelper") + +package awais.instagrabber.utils + +import awais.instagrabber.models.UploadPhotoOptions +import awais.instagrabber.models.UploadVideoOptions +import awais.instagrabber.models.enums.MediaItemType +import org.json.JSONObject +import java.time.Instant +import java.util.* +import kotlin.random.Random + + +private const val LOWER = 1000000000L +private const val UPPER = 9999999999L + +private fun createPhotoRuploadParams(options: UploadPhotoOptions): Map { + val imageCompression = mapOf( + "lib_name" to "moz", + "lib_version" to "3.1.m", + "quality" to "80", + ) + return listOfNotNull( + "retry_context" to retryContextString, + "media_type" to "1", + "upload_id" to (options.uploadId ?: ""), + "xsharing_user_ids" to "[]", + "image_compression" to JSONObject(imageCompression).toString(), + if (options.isSideCar) "is_sidecar" to "1" else null, + ).toMap() +} + +private fun createVideoRuploadParams(options: UploadVideoOptions): Map = listOfNotNull( + "retry_context" to retryContextString, + "media_type" to "2", + "xsharing_user_ids" to "[]", + "upload_id" to options.uploadId, + "upload_media_width" to options.width.toString(), + "upload_media_height" to options.height.toString(), + "upload_media_duration_ms" to options.duration.toString(), + if (options.isSideCar) "is_sidecar" to "1" else null, + if (options.forAlbum) "for_album" to "1" else null, + if (options.isDirect) "direct_v2" to "1" else null, + *(if (options.isForDirectStory) arrayOf( + "for_direct_story" to "1", + "content_tags" to "" + ) else emptyArray()), + if (options.isIgtvVideo) "is_igtv_video" to "1" else null, + if (options.isDirectVoice) "is_direct_voice" to "1" else null, +).toMap() + +val retryContextString: String + get() { + return JSONObject( + mapOf( + "num_step_auto_retry" to 0, + "num_reupload" to 0, + "num_step_manual_retry" to 0, + ) + ).toString() + } + +fun createUploadPhotoOptions(byteLength: Long): UploadPhotoOptions { + val uploadId = generateUploadId() + return UploadPhotoOptions( + uploadId, + generateName(uploadId), + byteLength, + ) +} + +fun createUploadDmVideoOptions( + byteLength: Long, + duration: Long, + width: Int, + height: Int +): UploadVideoOptions { + val uploadId = generateUploadId() + return UploadVideoOptions( + uploadId, + generateName(uploadId), + byteLength, + duration, + width, + height, + isDirect = true, + mediaType = MediaItemType.MEDIA_TYPE_VIDEO, + ) +} + +fun createUploadDmVoiceOptions( + byteLength: Long, + duration: Long +): UploadVideoOptions { + val uploadId = generateUploadId() + return UploadVideoOptions( + uploadId, + generateName(uploadId), + byteLength, + duration, + isDirectVoice = true, + mediaType = MediaItemType.MEDIA_TYPE_VOICE, + ) +} + +fun generateUploadId(): String { + return Instant.now().epochSecond.toString() +} + +fun generateName(uploadId: String): String { + val random = Random.nextLong(LOWER, UPPER + 1) + return "${uploadId}_0_$random" +} + +fun getUploadPhotoHeaders(options: UploadPhotoOptions): Map { + val waterfallId = options.waterfallId ?: UUID.randomUUID().toString() + val contentLength = options.byteLength.toString() + return mapOf( + "X_FB_PHOTO_WATERFALL_ID" to waterfallId, + "X-Entity-Type" to "image/jpeg", + "Offset" to "0", + "X-Instagram-Rupload-Params" to JSONObject(createPhotoRuploadParams(options)).toString(), + "X-Entity-Name" to options.name, + "X-Entity-Length" to contentLength, + "Content-Type" to "application/octet-stream", + "Content-Length" to contentLength, + "Accept-Encoding" to "gzip", + ) +} + +fun getUploadVideoHeaders(options: UploadVideoOptions): Map { + val ruploadParams = createVideoRuploadParams(options) + val waterfallId = options.waterfallId ?: UUID.randomUUID().toString() + val contentLength = options.byteLength.toString() + return getBaseUploadVideoHeaders(ruploadParams) + mapOf( + "X_FB_PHOTO_WATERFALL_ID" to waterfallId, + "X-Entity-Type" to "video/mp4", + "Offset" to (if (options.offset > 0) options.offset else 0).toString(), + "X-Entity-Name" to options.name, + "X-Entity-Length" to contentLength, + "Content-Type" to "application/octet-stream", + "Content-Length" to contentLength, + ) +} + +private fun getBaseUploadVideoHeaders(ruploadParams: Map): Map { + return mapOf( + "X-IG-Connection-Type" to "WIFI", + "X-IG-Capabilities" to "3brTvwE=", + "Accept-Encoding" to "gzip", + "X-Instagram-Rupload-Params" to JSONObject(ruploadParams).toString() + ) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/MediaUploader.kt b/app/src/main/java/awais/instagrabber/utils/MediaUploader.kt new file mode 100644 index 0000000..e709eb1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/MediaUploader.kt @@ -0,0 +1,122 @@ +package awais.instagrabber.utils + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import awais.instagrabber.models.UploadVideoOptions +import awais.instagrabber.webservices.interceptors.AddCookiesInterceptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.* +import okio.BufferedSink +import okio.Okio +import org.json.JSONObject +import ru.gildor.coroutines.okhttp.await +import java.io.IOException +import java.io.InputStream + +object MediaUploader { + private const val HOST = "https://i.instagram.com" + private val octetStreamMediaType: MediaType = requireNotNull(MediaType.parse("application/octet-stream")) { + "No media type found for application/octet-stream" + } + + suspend fun uploadPhoto( + uri: Uri, + contentResolver: ContentResolver, + ): MediaUploadResponse = withContext(Dispatchers.IO) { + val bitmapResult = BitmapUtils.loadBitmap(contentResolver, uri, 1000f, false) + val bitmap = bitmapResult?.bitmap ?: throw IOException("bitmap is null") + uploadPhoto(contentResolver, bitmap) + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun uploadPhoto( + contentResolver: ContentResolver, + bitmap: Bitmap, + ): MediaUploadResponse = withContext(Dispatchers.IO) { + val file: DocumentFile? = BitmapUtils.convertToJpegAndSaveToFile(contentResolver, bitmap, null) + val byteLength: Long = file!!.length() + val options = createUploadPhotoOptions(byteLength) + val headers = getUploadPhotoHeaders(options) + val url = HOST + "/rupload_igphoto/" + options.name + "/" + try { + contentResolver.openInputStream(file.uri).use { input -> + upload(input!!, url, headers) + } + } finally { + file.delete() + } + } + + @JvmStatic + @Suppress("BlockingMethodInNonBlockingContext") // See https://youtrack.jetbrains.com/issue/KTIJ-838 + suspend fun uploadVideo( + uri: Uri, + contentResolver: ContentResolver, + options: UploadVideoOptions, + ): MediaUploadResponse = withContext(Dispatchers.IO) { + val headers = getUploadVideoHeaders(options) + val url = HOST + "/rupload_igvideo/" + options.name + "/" + contentResolver.openInputStream(uri).use { input -> + if (input == null) { + // listener.onFailure(RuntimeException("InputStream was null")) + throw IllegalStateException("InputStream was null") + } + upload(input, url, headers) + } + } + + @Throws(IOException::class) + private suspend fun upload( + input: InputStream, + url: String, + headers: Map, + ): MediaUploadResponse { + try { + val client = OkHttpClient.Builder() + // .addInterceptor(new LoggingInterceptor()) + .addInterceptor(AddCookiesInterceptor()) + .followRedirects(false) + .followSslRedirects(false) + .build() + val request = Request.Builder() + .headers(Headers.of(headers)) + .url(url) + .post(create(octetStreamMediaType, input)) + .build() + return withContext(Dispatchers.IO) { + val response = client.newCall(request).await() + val body = response.body() + @Suppress("BlockingMethodInNonBlockingContext") // Blocked by https://github.com/square/okio/issues/501 + MediaUploadResponse(response.code(), if (body != null) JSONObject(body.string()) else null) + } + } catch (e: Exception) { + // rethrow for proper stacktrace. See https://github.com/gildor/kotlin-coroutines-okhttp/tree/master#wrap-exception-manually + throw IOException(e) + } + } + + private fun create(mediaType: MediaType, inputStream: InputStream): RequestBody = object : RequestBody() { + override fun contentType(): MediaType { + return mediaType + } + + override fun contentLength(): Long { + return try { + inputStream.available().toLong() + } catch (e: IOException) { + 0 + } + } + + @Throws(IOException::class) + @Suppress("DEPRECATION_ERROR") + override fun writeTo(sink: BufferedSink) { + Okio.source(inputStream).use { sink.writeAll(it) } + } + } + + data class MediaUploadResponse(val responseCode: Int, val response: JSONObject?) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/MediaUtils.java b/app/src/main/java/awais/instagrabber/utils/MediaUtils.java new file mode 100644 index 0000000..1642acb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/MediaUtils.java @@ -0,0 +1,96 @@ +package awais.instagrabber.utils; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.FileDescriptor; + +public final class MediaUtils { + private static final String TAG = MediaUtils.class.getSimpleName(); + + public static void getVideoInfo(@NonNull final ContentResolver contentResolver, + @NonNull final Uri uri, + @NonNull final OnInfoLoadListener listener) { + getInfo(contentResolver, uri, listener, true); + } + + public static void getVoiceInfo(@NonNull final ContentResolver contentResolver, + @NonNull final Uri uri, + @NonNull final OnInfoLoadListener listener) { + getInfo(contentResolver, uri, listener, false); + } + + private static void getInfo(@NonNull final ContentResolver contentResolver, + @NonNull final Uri uri, + @NonNull final OnInfoLoadListener listener, + @NonNull final Boolean isVideo) { + AppExecutors.INSTANCE.getTasksThread().submit(() -> { + try (ParcelFileDescriptor parcelFileDescriptor = contentResolver.openFileDescriptor(uri, "r")) { + if (parcelFileDescriptor == null) { + listener.onLoad(null); + return; + } + final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); + mediaMetadataRetriever.setDataSource(fileDescriptor); + String duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + if (TextUtils.isEmpty(duration)) duration = "0"; + if (isVideo) { + String width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH); + if (TextUtils.isEmpty(width)) width = "1"; + String height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT); + if (TextUtils.isEmpty(height)) height = "1"; + final Cursor cursor = contentResolver.query(uri, new String[]{MediaStore.Video.Media.SIZE}, null, null, null); + cursor.moveToFirst(); + final long fileSize = cursor.getLong(0); + cursor.close(); + listener.onLoad(new VideoInfo( + Long.parseLong(duration), + Integer.valueOf(width), + Integer.valueOf(height), + fileSize + )); + return; + } + listener.onLoad(new VideoInfo( + Long.parseLong(duration), + 0, + 0, + 0 + )); + } catch (Exception e) { + Log.e(TAG, "getInfo: ", e); + listener.onFailure(e); + } + }); + } + + public static class VideoInfo { + public long duration; + public int width; + public int height; + public long size; + + public VideoInfo(final long duration, final int width, final int height, final long size) { + this.duration = duration; + this.width = width; + this.height = height; + this.size = size; + } + + } + + public interface OnInfoLoadListener { + void onLoad(@Nullable T info); + + void onFailure(Throwable t); + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/NavigationExtensions.java b/app/src/main/java/awais/instagrabber/utils/NavigationExtensions.java new file mode 100644 index 0000000..02c8b0a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/NavigationExtensions.java @@ -0,0 +1,247 @@ +// package awais.instagrabber.utils; +// +// import android.annotation.SuppressLint; +// import android.content.Intent; +// import android.util.Log; +// import android.util.SparseArray; +// +// import androidx.annotation.NonNull; +// import androidx.fragment.app.Fragment; +// import androidx.fragment.app.FragmentManager; +// import androidx.fragment.app.FragmentTransaction; +// import androidx.lifecycle.LiveData; +// import androidx.lifecycle.MutableLiveData; +// import androidx.navigation.NavController; +// import androidx.navigation.NavDestination; +// import androidx.navigation.NavGraph; +// import androidx.navigation.fragment.NavHostFragment; +// +// import com.google.android.material.bottomnavigation.BottomNavigationView; +// +// import java.util.List; +// +// import awais.instagrabber.R; +// import awais.instagrabber.customviews.NavHostFragmentWithDefaultAnimations; +// import awais.instagrabber.fragments.main.FeedFragment; +// +// /** +// * This is a Java rewrite of NavigationExtensions +// * from architecture-components-samples. Some modifications have been done, check git history. +// */ +// public class NavigationExtensions { +// private static final String TAG = NavigationExtensions.class.getSimpleName(); +// private static String selectedItemTag; +// private static boolean isOnFirstFragment; +// +// @NonNull +// public static LiveData setupWithNavController(@NonNull final BottomNavigationView bottomNavigationView, +// @NonNull List navGraphIds, +// @NonNull final FragmentManager fragmentManager, +// final int containerId, +// @NonNull Intent intent, +// final int firstFragmentGraphIndex) { +// final SparseArray graphIdToTagMap = new SparseArray<>(); +// final MutableLiveData selectedNavController = new MutableLiveData<>(); +// int firstFragmentGraphId = 0; +// for (int i = 0; i < navGraphIds.size(); i++) { +// final int navGraphId = navGraphIds.get(i); +// final String fragmentTag = getFragmentTag(navGraphId); +// final NavHostFragment navHostFragment = obtainNavHostFragment(fragmentManager, fragmentTag, navGraphId, containerId); +// final NavController navController = navHostFragment.getNavController(); +// final int graphId = navController.getGraph().getId(); +// if (i == firstFragmentGraphIndex) { +// firstFragmentGraphId = graphId; +// } +// graphIdToTagMap.put(graphId, fragmentTag); +// if (bottomNavigationView.getSelectedItemId() == graphId) { +// selectedNavController.setValue(navHostFragment.getNavController()); +// attachNavHostFragment(fragmentManager, navHostFragment, i == firstFragmentGraphIndex); +// } else { +// detachNavHostFragment(fragmentManager, navHostFragment); +// } +// } +// selectedItemTag = graphIdToTagMap.get(bottomNavigationView.getSelectedItemId()); +// final String firstFragmentTag = graphIdToTagMap.get(firstFragmentGraphId); +// isOnFirstFragment = selectedItemTag != null && selectedItemTag.equals(firstFragmentTag); +// bottomNavigationView.setOnItemSelectedListener(item -> { +// if (fragmentManager.isStateSaved()) { +// return false; +// } +// String newlySelectedItemTag = graphIdToTagMap.get(item.getItemId()); +// String tag = selectedItemTag; +// if (tag != null && !tag.equals(newlySelectedItemTag)) { +// fragmentManager.popBackStack(firstFragmentTag, FragmentManager.POP_BACK_STACK_INCLUSIVE); +// Fragment fragment = fragmentManager.findFragmentByTag(newlySelectedItemTag); +// if (fragment == null) { +// return false; +// // throw new RuntimeException("null cannot be cast to non-null NavHostFragment"); +// } +// final NavHostFragment selectedFragment = (NavHostFragment) fragment; +// if (firstFragmentTag != null && !firstFragmentTag.equals(newlySelectedItemTag)) { +// FragmentTransaction fragmentTransaction = fragmentManager +// .beginTransaction() +// .setCustomAnimations( +// R.anim.nav_default_enter_anim, +// R.anim.nav_default_exit_anim, +// R.anim.nav_default_pop_enter_anim, +// R.anim.nav_default_pop_exit_anim +// ) +// .attach(selectedFragment) +// .setPrimaryNavigationFragment(selectedFragment); +// for (int i = 0; i < graphIdToTagMap.size(); i++) { +// final int key = graphIdToTagMap.keyAt(i); +// final String fragmentTagForId = graphIdToTagMap.get(key); +// if (!fragmentTagForId.equals(newlySelectedItemTag)) { +// final Fragment fragmentByTag = fragmentManager.findFragmentByTag(firstFragmentTag); +// if (fragmentByTag == null) { +// continue; +// } +// fragmentTransaction.detach(fragmentByTag); +// } +// } +// fragmentTransaction.addToBackStack(firstFragmentTag) +// .setReorderingAllowed(true) +// .commit(); +// } +// selectedItemTag = newlySelectedItemTag; +// isOnFirstFragment = selectedItemTag.equals(firstFragmentTag); +// selectedNavController.setValue(selectedFragment.getNavController()); +// return true; +// } +// return false; +// }); +// setupItemReselected(bottomNavigationView, graphIdToTagMap, fragmentManager); +// setupDeepLinks(bottomNavigationView, navGraphIds, fragmentManager, containerId, intent); +// final int finalFirstFragmentGraphId = firstFragmentGraphId; +// fragmentManager.addOnBackStackChangedListener(() -> { +// if (!isOnFirstFragment) { +// if (firstFragmentTag == null) { +// return; +// } +// if (!isOnBackStack(fragmentManager, firstFragmentTag)) { +// bottomNavigationView.setSelectedItemId(finalFirstFragmentGraphId); +// } +// } +// +// final NavController navController = selectedNavController.getValue(); +// if (navController != null && navController.getCurrentDestination() == null) { +// final NavGraph navControllerGraph = navController.getGraph(); +// navController.navigate(navControllerGraph.getId()); +// } +// }); +// return selectedNavController; +// } +// +// private static NavHostFragment obtainNavHostFragment(final FragmentManager fragmentManager, +// final String fragmentTag, +// final int navGraphId, +// final int containerId) { +// final NavHostFragment existingFragment = (NavHostFragment) fragmentManager.findFragmentByTag(fragmentTag); +// if (existingFragment != null) { +// return existingFragment; +// } +// final NavHostFragment navHostFragment = NavHostFragmentWithDefaultAnimations.create(navGraphId); +// fragmentManager.beginTransaction() +// .setReorderingAllowed(true) +// .add(containerId, navHostFragment, fragmentTag) +// .commitNow(); +// return navHostFragment; +// } +// +// private static void attachNavHostFragment(final FragmentManager fragmentManager, +// final NavHostFragment navHostFragment, +// final boolean isPrimaryNavFragment) { +// final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction() +// .attach(navHostFragment); +// if (isPrimaryNavFragment) { +// fragmentTransaction.setPrimaryNavigationFragment(navHostFragment); +// } +// fragmentTransaction.commitNow(); +// } +// +// private static void detachNavHostFragment(final FragmentManager fragmentManager, final NavHostFragment navHostFragment) { +// fragmentManager.beginTransaction() +// .detach(navHostFragment) +// .commitNow(); +// } +// +// @SuppressLint("RestrictedApi") +// private static void setupItemReselected(final BottomNavigationView bottomNavigationView, +// final SparseArray graphIdToTagMap, +// final FragmentManager fragmentManager) { +// bottomNavigationView.setOnItemReselectedListener(item -> { +// final String newlySelectedItemTag = graphIdToTagMap.get(item.getItemId()); +// final Fragment fragmentByTag = fragmentManager.findFragmentByTag(newlySelectedItemTag); +// if (fragmentByTag == null) { +// return; +// } +// final NavHostFragment selectedFragment = (NavHostFragment) fragmentByTag; +// final NavController navController = selectedFragment.getNavController(); +// final NavGraph navControllerGraph = navController.getGraph(); +// final NavDestination currentDestination = navController.getCurrentDestination(); +// final int startDestination = navControllerGraph.getStartDestination(); +// int backStackSize = navController.getBackStack().size(); +// if (currentDestination != null && backStackSize == 2 && currentDestination.getId() == startDestination) { +// // scroll to top +// final List fragments = selectedFragment.getChildFragmentManager().getFragments(); +// if (fragments.isEmpty()) return; +// final Fragment fragment = fragments.get(0); +// if (fragment instanceof FeedFragment) { +// ((FeedFragment) fragment).scrollToTop(); +// } +// return; +// } +// final boolean popped = navController.popBackStack(startDestination, false); +// backStackSize = navController.getBackStack().size(); +// if (!popped || backStackSize > 2) { +// try { +// // try loop pop +// do { +// navController.popBackStack(); +// backStackSize = navController.getBackStack().size(); +// } while (backStackSize > 2); +// } catch (Exception e) { +// Log.e(TAG, "setupItemReselected: ", e); +// } +// } +// }); +// } +// +// private static void setupDeepLinks(final BottomNavigationView bottomNavigationView, +// final List navGraphIds, +// final FragmentManager fragmentManager, +// final int containerId, +// final Intent intent) { +// for (int i = 0; i < navGraphIds.size(); i++) { +// final int navGraphId = navGraphIds.get(i); +// final String fragmentTag = getFragmentTag(navGraphId); +// final NavHostFragment navHostFragment = obtainNavHostFragment(fragmentManager, fragmentTag, navGraphId, containerId); +// if (navHostFragment.getNavController().handleDeepLink(intent)) { +// final int selectedItemId = bottomNavigationView.getSelectedItemId(); +// NavController navController = navHostFragment.getNavController(); +// NavGraph graph = navController.getGraph(); +// if (selectedItemId != graph.getId()) { +// navController = navHostFragment.getNavController(); +// graph = navController.getGraph(); +// bottomNavigationView.setSelectedItemId(graph.getId()); +// } +// } +// } +// } +// +// private static boolean isOnBackStack(final FragmentManager fragmentManager, final String backStackName) { +// int backStackCount = fragmentManager.getBackStackEntryCount(); +// for (int i = 0; i < backStackCount; i++) { +// final FragmentManager.BackStackEntry backStackEntry = fragmentManager.getBackStackEntryAt(i); +// final String name = backStackEntry.getName(); +// if (name != null && name.equals(backStackName)) { +// return true; +// } +// } +// return false; +// } +// +// private static String getFragmentTag(final int index) { +// return "bottomNavigation#" + index; +// } +// } diff --git a/app/src/main/java/awais/instagrabber/utils/NavigationHelper.kt b/app/src/main/java/awais/instagrabber/utils/NavigationHelper.kt new file mode 100644 index 0000000..0f474bf --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/NavigationHelper.kt @@ -0,0 +1,174 @@ +package awais.instagrabber.utils + +import android.content.Context +import android.content.res.Resources +import androidx.annotation.ArrayRes +import awais.instagrabber.R +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.models.Tab + +var tabOrderString: String? = null + +private val NON_REMOVABLE_NAV_ROOT_IDS: List = listOf(R.id.profile_nav_graph, R.id.more_nav_graph) + + +fun getLoggedInNavTabs(context: Context): Pair, List> { + val navRootIds = getArrayResIds(context.resources, R.array.logged_in_nav_root_ids) + return getTabs(context, navRootIds) +} + +fun getAnonNavTabs(context: Context): List { + val navRootIds = getArrayResIds(context.resources, R.array.anon_nav_root_ids) + val (tabs, _) = getTabs(context, navRootIds, true) + return tabs +} + +private fun getTabs( + context: Context, + navRootIds: IntArray, + isAnon: Boolean = false, +): Pair, MutableList> { + val navGraphNames = getResIdsForNavRootIds(navRootIds, ::getNavGraphNameForNavRootId) + val navGraphResIds = getResIdsForNavRootIds(navRootIds, ::getNavGraphResIdForNavRootId) + val titleArray = getResIdsForNavRootIds(navRootIds, ::getTitleResIdForNavRootId) + val iconIds = getResIdsForNavRootIds(navRootIds, ::getIconResIdForNavRootId) + val startDestFragIds = getResIdsForNavRootIds(navRootIds, ::getStartDestFragIdForNavRootId) + val (orderedGraphNames, orderedNavRootIds) = if (isAnon) navGraphNames to navRootIds.toList() else getOrderedNavRootIdsFromPref(navGraphNames) + val tabs = mutableListOf() + val otherTabs = mutableListOf() // Will contain tabs not in current list + for (i in navRootIds.indices) { + val navRootId = navRootIds[i] + val tab = Tab( + iconIds[i], + context.getString(titleArray[i]), + if (isAnon) false else !NON_REMOVABLE_NAV_ROOT_IDS.contains(navRootId), + navGraphResIds[i], + navRootId, + startDestFragIds[i] + ) + if (!isAnon && !orderedGraphNames.contains(navGraphNames[i])) { + otherTabs.add(tab) + continue + } + tabs.add(tab) + } + val associateBy = tabs.associateBy { it.navigationRootId } + val orderedTabs = orderedNavRootIds.mapNotNull { associateBy[it] } + return orderedTabs to otherTabs +} + +private fun getArrayResIds(resources: Resources, @ArrayRes arrayRes: Int): IntArray { + val typedArray = resources.obtainTypedArray(arrayRes) + val length = typedArray.length() + val navRootIds = IntArray(length) + for (i in 0 until length) { + val resourceId = typedArray.getResourceId(i, 0) + if (resourceId == 0) continue + navRootIds[i] = resourceId + } + typedArray.recycle() + return navRootIds +} + +private fun getResIdsForNavRootIds(navRootIds: IntArray, resMapper: Function1): List = navRootIds + .asSequence() + .filterNot { it == 0 } + .map(resMapper) + .filterNot { it == 0 } + .toList() + +private fun getTitleResIdForNavRootId(id: Int): Int = when (id) { + R.id.direct_messages_nav_graph -> R.string.title_dm + R.id.feed_nav_graph -> R.string.feed + R.id.profile_nav_graph -> R.string.profile + R.id.discover_nav_graph -> R.string.title_discover + R.id.more_nav_graph -> R.string.more + R.id.favorites_nav_graph -> R.string.title_favorites + R.id.notification_viewer_nav_graph -> R.string.title_notifications + else -> 0 +} + +private fun getIconResIdForNavRootId(id: Int): Int = when (id) { + R.id.direct_messages_nav_graph -> R.drawable.ic_message_24 + R.id.feed_nav_graph -> R.drawable.ic_home_24 + R.id.profile_nav_graph -> R.drawable.ic_person_24 + R.id.discover_nav_graph -> R.drawable.ic_explore_24 + R.id.more_nav_graph -> R.drawable.ic_more_horiz_24 + R.id.favorites_nav_graph -> R.drawable.ic_star_24 + R.id.notification_viewer_nav_graph -> R.drawable.ic_not_liked + else -> 0 +} + +private fun getStartDestFragIdForNavRootId(id: Int): Int = when (id) { + R.id.direct_messages_nav_graph -> R.id.directMessagesInboxFragment + R.id.feed_nav_graph -> R.id.feedFragment + R.id.profile_nav_graph -> R.id.profileFragment + R.id.discover_nav_graph -> R.id.discoverFragment + R.id.more_nav_graph -> R.id.morePreferencesFragment + R.id.favorites_nav_graph -> R.id.favoritesFragment + R.id.notification_viewer_nav_graph -> R.id.notificationsViewer + else -> 0 +} + +fun getNavGraphNameForNavRootId(id: Int): String = when (id) { + R.id.direct_messages_nav_graph -> "direct_messages_nav_graph" + R.id.feed_nav_graph -> "feed_nav_graph" + R.id.profile_nav_graph -> "profile_nav_graph" + R.id.discover_nav_graph -> "discover_nav_graph" + R.id.more_nav_graph -> "more_nav_graph" + R.id.favorites_nav_graph -> "favorites_nav_graph" + R.id.notification_viewer_nav_graph -> "notification_viewer_nav_graph" + else -> "" +} + +fun getNavGraphResIdForNavRootId(id: Int): Int = when (id) { + R.id.direct_messages_nav_graph -> R.navigation.direct_messages_nav_graph + R.id.feed_nav_graph -> R.navigation.feed_nav_graph + R.id.profile_nav_graph -> R.navigation.profile_nav_graph + R.id.discover_nav_graph -> R.navigation.discover_nav_graph + R.id.more_nav_graph -> R.navigation.more_nav_graph + R.id.favorites_nav_graph -> R.navigation.favorites_nav_graph + R.id.notification_viewer_nav_graph -> R.navigation.notification_viewer_nav_graph + else -> 0 +} + +private fun getNavRootIdForGraphName(navGraphName: String): Int = when (navGraphName) { + "direct_messages_nav_graph" -> R.id.direct_messages_nav_graph + "feed_nav_graph" -> R.id.feed_nav_graph + "profile_nav_graph" -> R.id.profile_nav_graph + "discover_nav_graph" -> R.id.discover_nav_graph + "more_nav_graph" -> R.id.more_nav_graph + "favorites_nav_graph" -> R.id.favorites_nav_graph + "notification_viewer_nav_graph" -> R.id.notification_viewer_nav_graph + else -> 0 +} + +private fun getOrderedNavRootIdsFromPref(navGraphNames: List): Pair, List> { + tabOrderString = Utils.settingsHelper.getString(PreferenceKeys.PREF_TAB_ORDER) + if (tabOrderString.isNullOrBlank()) { + // Use top 5 entries for default list + val top5navGraphNames: List = navGraphNames.subList(0, 5) + val newOrderString = top5navGraphNames.joinToString(",") + Utils.settingsHelper.putString(PreferenceKeys.PREF_TAB_ORDER, newOrderString) + tabOrderString = newOrderString + return top5navGraphNames to top5navGraphNames.map(::getNavRootIdForGraphName) + } + val orderString = tabOrderString ?: return navGraphNames to navGraphNames.subList(0, 5).map(::getNavRootIdForGraphName) + // Make sure that the list from preference does not contain any invalid values + val orderGraphNames = orderString + .split(",") + .asSequence() + .filter(String::isNotBlank) + .filter(navGraphNames::contains) + .toList() + val graphNames = if (orderGraphNames.isEmpty()) { + // Use top 5 entries for default list + navGraphNames.subList(0, 5) + } else orderGraphNames + return graphNames to graphNames.map(::getNavRootIdForGraphName) +} + +fun isNavRootInCurrentTabs(navRootString: String?): Boolean { + val navRoot = navRootString ?: return false + return tabOrderString?.contains(navRoot) ?: false +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/NetworkUtils.java b/app/src/main/java/awais/instagrabber/utils/NetworkUtils.java new file mode 100644 index 0000000..1d6e1ea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/NetworkUtils.java @@ -0,0 +1,58 @@ +package awais.instagrabber.utils; + +import androidx.annotation.NonNull; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.util.Map; +import java.util.Set; + +public final class NetworkUtils { + @NonNull + public static String readFromConnection(@NonNull final HttpURLConnection conn) throws Exception { + final InputStream inputStream = conn.getInputStream(); + return readFromInputStream(inputStream); + } + + @NonNull + public static String readFromInputStream(final InputStream inputStream) throws IOException { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = br.readLine()) != null) sb.append(line).append('\n'); + } + return sb.toString(); + } + + public static void setConnectionHeaders(final HttpURLConnection connection, final Map headers) { + if (connection == null || headers == null || headers.isEmpty()) { + return; + } + for (Map.Entry header : headers.entrySet()) { + connection.setRequestProperty(header.getKey(), header.getValue()); + } + } + + public static String getQueryString(final Map queryParamsMap) { + if (queryParamsMap == null || queryParamsMap.isEmpty()) { + return ""; + } + final Set> params = queryParamsMap.entrySet(); + final StringBuilder builder = new StringBuilder(); + for (final Map.Entry param : params) { + if (TextUtils.isEmpty(param.getKey())) { + continue; + } + if (builder.length() != 0) { + builder.append("&"); + } + builder.append(param.getKey()); + builder.append("="); + builder.append(param.getValue() != null ? param.getValue() : ""); + } + return builder.toString(); + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/NullSafePair.kt b/app/src/main/java/awais/instagrabber/utils/NullSafePair.kt new file mode 100644 index 0000000..e0dd253 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/NullSafePair.kt @@ -0,0 +1,42 @@ +package awais.instagrabber.utils + +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Container to ease passing around a tuple of two objects. This object provides a sensible + * implementation of equals(), returning true if equals() is true on each of the contained + * objects. + */ +/** + * Constructor for a Pair. + * + * @param first the first object in the Pair + * @param second the second object in the pair + */ +data class NullSafePair(@JvmField val first: F, @JvmField val second: S) { + companion object { + /** + * Convenience method for creating an appropriately typed pair. + * + * @param a the first object in the Pair + * @param b the second object in the pair + * @return a Pair that is templatized with the types of a and b + */ + fun create(a: A, b: B): NullSafePair { + return NullSafePair(a, b) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/NumberUtils.kt b/app/src/main/java/awais/instagrabber/utils/NumberUtils.kt new file mode 100644 index 0000000..ab47535 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/NumberUtils.kt @@ -0,0 +1,87 @@ +@file:JvmName("NumberUtils") + +package awais.instagrabber.utils + +import java.util.* +import kotlin.math.ln +import kotlin.math.pow + +fun getResultingHeight(requiredWidth: Int, height: Int, width: Int): Int { + return requiredWidth * height / width +} + +fun getResultingWidth(requiredHeight: Int, height: Int, width: Int): Int { + return requiredHeight * width / height +} + +// TODO Replace all usages with kotlin Random.nextLong() once converted to kotlin +fun random(origin: Long, bound: Long): Long { + val random = Random() + var r = random.nextLong() + val n = bound - origin + val m = n - 1 + when { + n and m == 0L -> r = (r and m) + origin // power of two + n > 0L -> { + // reject over-represented candidates + var u = r ushr 1 // ensure non-negative + while (u + m - u % n.also { r = it } < 0L) { // rejection check + // retry + u = random.nextLong() ushr 1 + } + r += origin + } + else -> { + // range not representable as long + while (r < origin || r >= bound) r = random.nextLong() + } + } + return r +} + +fun calculateWidthHeight(height: Int, width: Int, maxHeight: Int, maxWidth: Int): NullSafePair { + if (width > maxWidth) { + var tempHeight = getResultingHeight(maxWidth, height, width) + var tempWidth = maxWidth + if (tempHeight > maxHeight) { + tempWidth = getResultingWidth(maxHeight, tempHeight, tempWidth) + tempHeight = maxHeight + } + return NullSafePair(tempWidth, tempHeight) + } + if (height < maxHeight && width < maxWidth || height > maxHeight) { + var tempWidth = getResultingWidth(maxHeight, height, width) + var tempHeight = maxHeight + if (tempWidth > maxWidth) { + tempHeight = getResultingHeight(maxWidth, tempHeight, tempWidth) + tempWidth = maxWidth + } + return NullSafePair(tempWidth, tempHeight) + } + return NullSafePair(width, height) +} + +fun roundFloat2Decimals(value: Float): Float { + return ((value + (if (value >= 0) 1 else -1) * 0.005f) * 100).toInt() / 100f +} + +fun abbreviate(number: Long, options: AbbreviateOptions? = null): String { + // adapted from https://stackoverflow.com/a/9769590/1436766 + var threshold = 1000 + var addSpace = false + if (options != null) { + threshold = options.threshold + addSpace = options.addSpaceBeforePrefix + } + if (number < threshold) return "" + number + val exp = (ln(number.toDouble()) / ln(threshold.toDouble())).toInt() + return String.format( + Locale.US, + "%.1f%s%c", + number / threshold.toDouble().pow(exp.toDouble()), + if (addSpace) " " else "", + "kMGTPE"[exp - 1] + ) +} + +data class AbbreviateOptions(val threshold: Int = 1000, val addSpaceBeforePrefix: Boolean = false) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/PasswordUtils.kt b/app/src/main/java/awais/instagrabber/utils/PasswordUtils.kt new file mode 100644 index 0000000..2d4b13c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/PasswordUtils.kt @@ -0,0 +1,52 @@ +package awais.instagrabber.utils + +import android.util.Base64 +import java.security.GeneralSecurityException +import java.security.InvalidAlgorithmParameterException +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.NoSuchPaddingException +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object PasswordUtils { + private const val cipherAlgo = "AES" + private const val cipherTran = "AES/CBC/PKCS5Padding" + @JvmStatic + @Throws(Exception::class) + fun dec(encrypted: String?, keyValue: ByteArray?): ByteArray { + return try { + val cipher = Cipher.getInstance(cipherTran) + val secretKey = SecretKeySpec(keyValue, cipherAlgo) + cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) + cipher.doFinal(Base64.decode(encrypted, Base64.DEFAULT or Base64.NO_PADDING or Base64.NO_WRAP)) + } catch (e: NoSuchAlgorithmException) { + throw IncorrectPasswordException(e) + } catch (e: NoSuchPaddingException) { + throw IncorrectPasswordException(e) + } catch (e: InvalidAlgorithmParameterException) { + throw IncorrectPasswordException(e) + } catch (e: InvalidKeyException) { + throw IncorrectPasswordException(e) + } catch (e: BadPaddingException) { + throw IncorrectPasswordException(e) + } catch (e: IllegalBlockSizeException) { + throw IncorrectPasswordException(e) + } + } + + @JvmStatic + @Throws(Exception::class) + fun enc(str: String, keyValue: ByteArray?): ByteArray { + val cipher = Cipher.getInstance(cipherTran) + val secretKey = SecretKeySpec(keyValue, cipherAlgo) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) + val bytes = cipher.doFinal(str.toByteArray()) + return Base64.encode(bytes, Base64.DEFAULT or Base64.NO_PADDING or Base64.NO_WRAP) + } + + class IncorrectPasswordException(e: GeneralSecurityException?) : Exception(e) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/PermissionUtils.kt b/app/src/main/java/awais/instagrabber/utils/PermissionUtils.kt new file mode 100644 index 0000000..2071c24 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/PermissionUtils.kt @@ -0,0 +1,53 @@ +package awais.instagrabber.utils + +import android.Manifest.permission +import android.content.Context +import androidx.core.content.PermissionChecker +import awais.instagrabber.utils.PermissionUtils +import androidx.core.content.ContextCompat +import android.content.pm.PackageManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.fragment.app.Fragment + +object PermissionUtils { + val AUDIO_RECORD_PERMS = arrayOf(permission.RECORD_AUDIO) + val ATTACH_MEDIA_PERMS = arrayOf(permission.READ_EXTERNAL_STORAGE) + val CAMERA_PERMS = arrayOf(permission.CAMERA) + @JvmStatic + fun hasAudioRecordPerms(context: Context): Boolean { + return PermissionChecker.checkSelfPermission( + context, + permission.RECORD_AUDIO + ) == PermissionChecker.PERMISSION_GRANTED + } + + @JvmStatic + fun requestAudioRecordPerms(fragment: Fragment, requestCode: Int) { + fragment.requestPermissions(AUDIO_RECORD_PERMS, requestCode) + } + + @JvmStatic + fun hasAttachMediaPerms(context: Context): Boolean { + return PermissionChecker.checkSelfPermission( + context, + permission.READ_EXTERNAL_STORAGE + ) == PermissionChecker.PERMISSION_GRANTED + } + + @JvmStatic + fun requestAttachMediaPerms(fragment: Fragment, requestCode: Int) { + fragment.requestPermissions(ATTACH_MEDIA_PERMS, requestCode) + } + + fun hasCameraPerms(context: Context?): Boolean { + return ContextCompat.checkSelfPermission( + context!!, + permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + fun requestCameraPerms(activity: AppCompatActivity?, requestCode: Int) { + ActivityCompat.requestPermissions(activity!!, CAMERA_PERMS, requestCode) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/ProcessPhoenix.java b/app/src/main/java/awais/instagrabber/utils/ProcessPhoenix.java new file mode 100644 index 0000000..5dcc6e1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ProcessPhoenix.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2014 Jake Wharton + * + * 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 awais.instagrabber.utils; + +import android.app.Activity; +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Process; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; + +/** + * Process Phoenix facilitates restarting your application process. This should only be used for + * things like fundamental state changes in your debug builds (e.g., changing from staging to + * production). + *

+ * Trigger process recreation by calling {@link #triggerRebirth} with a {@link Context} instance. + */ +public final class ProcessPhoenix extends Activity { + private static final String KEY_RESTART_INTENTS = "phoenix_restart_intents"; + + /** + * Call to restart the application process using the {@linkplain Intent#CATEGORY_DEFAULT default} + * activity as an intent. + *

+ * Behavior of the current process after invoking this method is undefined. + */ + public static void triggerRebirth(Context context) { + triggerRebirth(context, getRestartIntent(context)); + } + + /** + * Call to restart the application process using the specified intents. + *

+ * Behavior of the current process after invoking this method is undefined. + */ + public static void triggerRebirth(Context context, Intent... nextIntents) { + Intent intent = new Intent(context, ProcessPhoenix.class); + intent.addFlags(FLAG_ACTIVITY_NEW_TASK); // In case we are called with non-Activity context. + intent.putParcelableArrayListExtra(KEY_RESTART_INTENTS, new ArrayList<>(Arrays.asList(nextIntents))); + context.startActivity(intent); + if (context instanceof Activity) { + ((Activity) context).finish(); + } + Runtime.getRuntime().exit(0); // Kill kill kill! + } + + private static Intent getRestartIntent(Context context) { + String packageName = context.getPackageName(); + Intent defaultIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); + if (defaultIntent != null) { + defaultIntent.addFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK); + return defaultIntent; + } + + throw new IllegalStateException("Unable to determine default activity for " + + packageName + + ". Does an activity specify the DEFAULT category in its intent filter?"); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + ArrayList intents = getIntent().getParcelableArrayListExtra(KEY_RESTART_INTENTS); + startActivities(intents.toArray(new Intent[intents.size()])); + finish(); + Runtime.getRuntime().exit(0); // Kill kill kill! + } + + /** + * Checks if the current process is a temporary Phoenix Process. + * This can be used to avoid initialisation of unused resources or to prevent running code that + * is not multi-process ready. + * + * @return true if the current process is a temporary Phoenix Process + */ + public static boolean isPhoenixProcess(Context context) { + int currentPid = Process.myPid(); + ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + List runningProcesses = manager.getRunningAppProcesses(); + if (runningProcesses != null) { + for (ActivityManager.RunningAppProcessInfo processInfo : runningProcesses) { + if (processInfo.pid == currentPid && processInfo.processName.endsWith(":phoenix")) { + return true; + } + } + } + return false; + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.kt b/app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.kt new file mode 100644 index 0000000..29a28ba --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.kt @@ -0,0 +1,27 @@ +package awais.instagrabber.utils + +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.repositories.responses.directmessages.RankedRecipientsResponse +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +object RankedRecipientsCache { + private var lastUpdatedOn: LocalDateTime? = null + var isUpdateInitiated = false + var isFailed = false + val rankedRecipients: List + get() = response?.rankedRecipients ?: emptyList() + + var response: RankedRecipientsResponse? = null + set(value) { + field = value + lastUpdatedOn = LocalDateTime.now() + } + + val isExpired: Boolean + get() { + if (lastUpdatedOn == null || response == null) return true + val expiresInSecs = response!!.expires + return LocalDateTime.now().isAfter(lastUpdatedOn!!.plus(expiresInSecs, ChronoUnit.SECONDS)) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/ResponseBodyUtils.java b/app/src/main/java/awais/instagrabber/utils/ResponseBodyUtils.java new file mode 100644 index 0000000..fb5f14e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ResponseBodyUtils.java @@ -0,0 +1,375 @@ +package awais.instagrabber.utils; + +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import awais.instagrabber.models.enums.MediaItemType; +import awais.instagrabber.repositories.responses.Caption; +import awais.instagrabber.repositories.responses.FriendshipStatus; +import awais.instagrabber.repositories.responses.ImageVersions2; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.MediaCandidate; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.stories.StoryMedia; + +public final class ResponseBodyUtils { + private static final String TAG = "ResponseBodyUtils"; + + // isI: true if the content was requested from i.instagram.com instead of graphql + @Nullable + public static String getHighQualityPost(final JSONArray resources, final boolean isVideo, final boolean isI, final boolean low) { + try { + final int resourcesLen = resources.length(); + + final String[] sources = new String[resourcesLen]; + int lastResMain = low ? 1000000 : 0, lastIndexMain = -1; + int lastResBase = low ? 1000000 : 0, lastIndexBase = -1; + for (int i = 0; i < resourcesLen; ++i) { + final JSONObject item = resources.getJSONObject(i); + if (item != null && (!isVideo || item.has(Constants.EXTRAS_PROFILE) || isI)) { + sources[i] = item.getString(isI ? "url" : "src"); + final int currRes = item.getInt(isI ? "width" : "config_width") * item.getInt(isI ? "height" : "config_height"); + + final String profile = isVideo ? item.optString(Constants.EXTRAS_PROFILE) : null; + + if (!isVideo || "MAIN".equals(profile)) { + if (currRes > lastResMain && !low) { + lastResMain = currRes; + lastIndexMain = i; + } else if (currRes < lastResMain && low) { + lastResMain = currRes; + lastIndexMain = i; + } + } else { + if (currRes > lastResBase && !low) { + lastResBase = currRes; + lastIndexBase = i; + } else if (currRes < lastResBase && low) { + lastResBase = currRes; + lastIndexBase = i; + } + } + } + } + + if (lastIndexMain >= 0) return sources[lastIndexMain]; + else if (lastIndexBase >= 0) return sources[lastIndexBase]; + } catch (final Exception e) { + Log.e(TAG, "", e); + } + return null; + } + + public static String getHighQualityImage(final JSONObject resources) { + String src = null; + try { + if (resources.has("display_resources")) + src = getHighQualityPost(resources.getJSONArray("display_resources"), false, false, false); + else if (resources.has("image_versions2")) + src = getHighQualityPost(resources.getJSONObject("image_versions2").getJSONArray("candidates"), false, true, false); + if (src == null) return resources.getString("display_url"); + } catch (final Exception e) { + Log.e(TAG, "", e); + } + return src; + } + + // the "user" argument can be null, it's used because instagram redacts user details from responses + public static Media parseGraphQLItem(final JSONObject itemJson, final User backup) throws JSONException { + if (itemJson == null) { + return null; + } + final JSONObject feedItem = itemJson.has("node") ? itemJson.getJSONObject("node") : itemJson; + final String mediaType = feedItem.optString("__typename"); + if ("GraphSuggestedUserFeedUnit".equals(mediaType)) return null; + + final boolean isVideo = feedItem.optBoolean("is_video"); + final long videoViews = feedItem.optLong("video_view_count", 0); + + final String displayUrl = feedItem.optString("display_url"); + if (TextUtils.isEmpty(displayUrl)) return null; + final String resourceUrl; + if (isVideo && feedItem.has("video_url")) { + resourceUrl = feedItem.getString("video_url"); + } else { + resourceUrl = feedItem.has("display_resources") ? ResponseBodyUtils.getHighQualityImage(feedItem) : displayUrl; + } + JSONObject tempJsonObject = feedItem.optJSONObject("edge_media_preview_comment"); + final long commentsCount = tempJsonObject != null ? tempJsonObject.optLong("count") : 0; + tempJsonObject = feedItem.optJSONObject("edge_media_preview_like"); + final long likesCount = tempJsonObject != null ? tempJsonObject.optLong("count") : 0; + tempJsonObject = feedItem.optJSONObject("edge_media_to_caption"); + final JSONArray captions = tempJsonObject != null ? tempJsonObject.getJSONArray("edges") : null; + String captionText = null; + if (captions != null && captions.length() > 0) { + if ((tempJsonObject = captions.optJSONObject(0)) != null && + (tempJsonObject = tempJsonObject.optJSONObject("node")) != null) { + captionText = tempJsonObject.getString("text"); + } + } + final JSONObject locationJson = feedItem.optJSONObject("location"); + // Log.d(TAG, "location: " + (location == null ? null : location.toString())); + long locationId = 0; + String locationName = null; + if (locationJson != null) { + locationName = locationJson.optString("name"); + if (locationJson.has("id")) { + locationId = locationJson.optLong("id"); + } else if (locationJson.has("pk")) { + locationId = locationJson.optLong("pk"); + } + // Log.d(TAG, "locationId: " + locationId); + } + int height = 0; + int width = 0; + final JSONObject dimensions = feedItem.optJSONObject("dimensions"); + if (dimensions != null) { + height = dimensions.optInt("height"); + width = dimensions.optInt("width"); + } + String thumbnailUrl = null; + final List candidates = new ArrayList(); + if (feedItem.has("display_resources") || feedItem.has("thumbnail_resources")) { + final JSONArray displayResources = feedItem.has("display_resources") + ? feedItem.getJSONArray("display_resources") + : feedItem.getJSONArray("thumbnail_resources"); + for (int i = 0; i < displayResources.length(); i++) { + final JSONObject displayResource = displayResources.getJSONObject(i); + candidates.add(new MediaCandidate( + displayResource.getInt("config_width"), + displayResource.getInt("config_height"), + displayResource.getString("src") + )); + } + } + final ImageVersions2 imageVersions2 = new ImageVersions2(candidates); + + User user = backup; + long userId = -1; + if (feedItem.has("owner") && user == null) { + final JSONObject owner = feedItem.getJSONObject("owner"); + final FriendshipStatus friendshipStatus = new FriendshipStatus( + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ); + userId = owner.optLong(Constants.EXTRAS_ID, -1); + user = new User( + userId, + owner.optString(Constants.EXTRAS_USERNAME), + owner.optString("full_name"), + false, + owner.optString("profile_pic_url"), + owner.optBoolean("is_verified")); + } + final String id = feedItem.getString(Constants.EXTRAS_ID); + MediaCandidate videoVersion = null; + if (isVideo) { + videoVersion = new MediaCandidate( + width, + height, + resourceUrl + ); + } + final Caption caption = new Caption( + userId, + captionText != null ? captionText : "" + ); + + final boolean isSlider = "GraphSidecar".equals(mediaType) && feedItem.has("edge_sidecar_to_children"); + List childItems = null; + if (isSlider) { + childItems = new ArrayList<>(); + // feedModelBuilder.setItemType(MediaItemType.MEDIA_TYPE_SLIDER); + final JSONObject sidecar = feedItem.optJSONObject("edge_sidecar_to_children"); + if (sidecar != null) { + final JSONArray children = sidecar.optJSONArray("edges"); + if (children != null) { + // final List sliderItems = getSliderItems(children); + // feedModelBuilder.setSliderItems(sliderItems) + // .setImageHeight(sliderItems.get(0).getHeight()) + // .setImageWidth(sliderItems.get(0).getWidth()); + for (int i = 0; i < children.length(); i++) { + final JSONObject child = children.optJSONObject(i); + if (child == null) continue; + final Media media = parseGraphQLItem(child, null); + media.setSidecarChild(true); + childItems.add(media); + } + } + } + } + MediaItemType mediaItemType = MediaItemType.MEDIA_TYPE_IMAGE; + if (isSlider) { + mediaItemType = MediaItemType.MEDIA_TYPE_SLIDER; + } else if (isVideo) { + mediaItemType = MediaItemType.MEDIA_TYPE_VIDEO; + } + final Location location = new Location( + locationId, + locationName, + locationName, + null, + null, + -1, + -1 + ); + return new Media( + id, + id, + feedItem.optString(Constants.EXTRAS_SHORTCODE), + feedItem.optLong("taken_at_timestamp", -1), + user, + false, + imageVersions2, + width, + height, + mediaItemType.getId(), + false, + feedItem.optBoolean("comments_disabled"), + -1, + commentsCount, + likesCount, + false, + false, + isVideo ? Collections.singletonList(videoVersion) : null, + feedItem.optBoolean("has_audio"), + feedItem.optDouble("video_duration"), + videoViews, + caption, + false, + null, + null, + childItems, + location, + null, + false, + false, + null, + null, + null + ); + } + + public static String getThumbUrl(final Object media) { + return getImageCandidate(media, CandidateType.THUMBNAIL); + } + + public static String getImageUrl(final Object media) { + return getImageCandidate(media, CandidateType.DOWNLOAD); + } + + private static String getImageCandidate(final Object rawMedia, final CandidateType type) { + if (rawMedia == null) return null; + final ImageVersions2 imageVersions2; + final int originalWidth, originalHeight; + if (rawMedia instanceof StoryMedia) { + imageVersions2 = ((StoryMedia) rawMedia).getImageVersions2(); + originalWidth = ((StoryMedia) rawMedia).getOriginalWidth(); + originalHeight = ((StoryMedia) rawMedia).getOriginalHeight(); + } + else if (rawMedia instanceof Media) { + imageVersions2 = ((Media) rawMedia).getImageVersions2(); + originalWidth = ((Media) rawMedia).getOriginalWidth(); + originalHeight = ((Media) rawMedia).getOriginalHeight(); + } + else return null; + if (imageVersions2 == null) return null; + final List candidates = imageVersions2.getCandidates(); + if (candidates == null || candidates.isEmpty()) return null; + final boolean isSquare = Integer.compare(originalWidth, originalHeight) == 0; + final List sortedCandidates = candidates.stream() + .sorted((c1, c2) -> Integer.compare(c2.getWidth(), c1.getWidth())) + .collect(Collectors.toList()); + final List filteredCandidates = sortedCandidates.stream() + .filter(c -> + c.getWidth() <= originalWidth + && c.getWidth() <= type.getValue() + && (isSquare || Integer + .compare(c.getWidth(), c.getHeight()) != 0) + ) + .collect(Collectors.toList()); + if (filteredCandidates.size() == 0) return sortedCandidates.get(0).getUrl(); + final MediaCandidate candidate = filteredCandidates.get(0); + if (candidate == null) return null; + return candidate.getUrl(); + } + + public static String getThumbVideoUrl(final Media media) { + return getVideoCandidate(media, CandidateType.VIDEO_THUMBNAIL); + } + + public static String getVideoUrl(final Object media) { + return getVideoCandidate(media, CandidateType.DOWNLOAD); + } + + // TODO: merge with getImageCandidate when Kotlin + private static String getVideoCandidate(final Object rawMedia, final CandidateType type) { + if (rawMedia == null) return null; + final List candidates; + final int originalWidth, originalHeight; + if (rawMedia instanceof StoryMedia) { + candidates = ((StoryMedia) rawMedia).getVideoVersions(); + originalWidth = ((StoryMedia) rawMedia).getOriginalWidth(); + originalHeight = ((StoryMedia) rawMedia).getOriginalHeight(); + } + else if (rawMedia instanceof Media) { + candidates = ((Media) rawMedia).getVideoVersions(); + originalWidth = ((Media) rawMedia).getOriginalWidth(); + originalHeight = ((Media) rawMedia).getOriginalHeight(); + } + else return null; + if (candidates == null || candidates.isEmpty()) return null; + final boolean isSquare = Integer.compare(originalWidth, originalHeight) == 0; + final List sortedCandidates = candidates.stream() + .sorted((c1, c2) -> Integer.compare(c2.getWidth(), c1.getWidth())) + .collect(Collectors.toList()); + final List filteredCandidates = sortedCandidates.stream() + .filter(c -> + c.getWidth() <= originalWidth + && c.getWidth() <= type.getValue() + && (isSquare || Integer + .compare(c.getWidth(), c.getHeight()) != 0) + ) + .collect(Collectors.toList()); + if (filteredCandidates.size() == 0) return sortedCandidates.get(0).getUrl(); + final MediaCandidate candidate = filteredCandidates.get(0); + if (candidate == null) return null; + return candidate.getUrl(); + } + + private enum CandidateType { + VIDEO_THUMBNAIL(700), + THUMBNAIL(1000), + DOWNLOAD(10000); + + private final int value; + + CandidateType(final int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/SerializablePair.kt b/app/src/main/java/awais/instagrabber/utils/SerializablePair.kt new file mode 100644 index 0000000..eb8ad6f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/SerializablePair.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.utils + +import android.util.Pair +import java.io.Serializable + +/** + * Constructor for a Pair. + * + * @param first the first object in the Pair + * @param second the second object in the pair + */ +data class SerializablePair(@JvmField val first: F, @JvmField val second: S) : Pair(first, second), Serializable \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt b/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt new file mode 100755 index 0000000..e59e21c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt @@ -0,0 +1,156 @@ +package awais.instagrabber.utils + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.annotation.StringDef +import androidx.appcompat.app.AppCompatDelegate +import awais.instagrabber.fragments.settings.PreferenceKeys +import java.util.* + +class SettingsHelper(context: Context) { + private val sharedPreferences: SharedPreferences? = context.getSharedPreferences(Constants.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun getString(@StringSettings key: String): String { + val stringDefault = getStringDefault(key) + return sharedPreferences?.getString( + key, + stringDefault + ) ?: stringDefault + } + + fun getStringSet(@StringSetSettings key: String?): Set { + val stringSetDefault: Set = HashSet() + return sharedPreferences?.getStringSet( + key, + stringSetDefault + ) ?: stringSetDefault + } + + fun getInteger(@IntegerSettings key: String): Int { + val integerDefault = getIntegerDefault(key) + return sharedPreferences?.getInt(key, integerDefault) ?: integerDefault + } + + fun getBoolean(@BooleanSettings key: String?): Boolean { + return sharedPreferences?.getBoolean(key, false) ?: false + } + + private fun getStringDefault(@StringSettings key: String): String { + if (PreferenceKeys.DATE_TIME_FORMAT == key) { + return Constants.defaultDateTimeFormat + } + return if (PreferenceKeys.DATE_TIME_SELECTION == key) "0;3;0" else "" + } + + private fun getIntegerDefault(@IntegerSettings key: String): Int { + if (PreferenceKeys.APP_THEME == key) return getThemeCode(true) + return if (Constants.PREV_INSTALL_VERSION == key || Constants.APP_UA_CODE == key || Constants.BROWSER_UA_CODE == key) -1 else 0 + } + + fun getThemeCode(fromHelper: Boolean): Int { + var themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + if (!fromHelper && sharedPreferences != null) { + themeCode = sharedPreferences.getString(PreferenceKeys.APP_THEME, themeCode.toString())?.toInt() ?: 0 + when (themeCode) { + 1 -> themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + 3 -> themeCode = AppCompatDelegate.MODE_NIGHT_NO + 0 -> themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } + if (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && Build.VERSION.SDK_INT < 29) { + themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + } + return themeCode + } + + fun putString(@StringSettings key: String?, `val`: String?) { + sharedPreferences?.edit()?.putString(key, `val`)?.apply() + } + + fun putStringSet(@StringSetSettings key: String?, `val`: Set?) { + sharedPreferences?.edit()?.putStringSet(key, `val`)?.apply() + } + + fun putInteger(@IntegerSettings key: String?, `val`: Int) { + sharedPreferences?.edit()?.putInt(key, `val`)?.apply() + } + + fun putBoolean(@BooleanSettings key: String?, `val`: Boolean) { + sharedPreferences?.edit()?.putBoolean(key, `val`)?.apply() + } + + fun hasPreference(key: String?): Boolean { + return sharedPreferences?.contains(key) ?: false + } + + @StringDef( + PreferenceKeys.APP_LANGUAGE, + PreferenceKeys.APP_THEME, + Constants.APP_UA, + Constants.BROWSER_UA, + Constants.COOKIE, + PreferenceKeys.FOLDER_PATH, + PreferenceKeys.DATE_TIME_FORMAT, + PreferenceKeys.DATE_TIME_SELECTION, + PreferenceKeys.CUSTOM_DATE_TIME_FORMAT, + Constants.DEVICE_UUID, + Constants.SKIPPED_VERSION, + Constants.DEFAULT_TAB, + Constants.PREF_DARK_THEME, + Constants.PREF_LIGHT_THEME, + Constants.PREF_POSTS_LAYOUT, + Constants.PREF_PROFILE_POSTS_LAYOUT, + Constants.PREF_TOPIC_POSTS_LAYOUT, + Constants.PREF_HASHTAG_POSTS_LAYOUT, + Constants.PREF_LOCATION_POSTS_LAYOUT, + Constants.PREF_LIKED_POSTS_LAYOUT, + Constants.PREF_TAGGED_POSTS_LAYOUT, + Constants.PREF_SAVED_POSTS_LAYOUT, + PreferenceKeys.STORY_SORT, + Constants.PREF_EMOJI_VARIANTS, + Constants.PREF_REACTIONS, + PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT, + PreferenceKeys.PREF_TAB_ORDER, + PreferenceKeys.PREF_BARINSTA_DIR_URI + ) + annotation class StringSettings + + @StringDef( + PreferenceKeys.DOWNLOAD_USER_FOLDER, + PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME, + PreferenceKeys.AUTOPLAY_VIDEOS_STORIES, + PreferenceKeys.MUTED_VIDEOS, +// PreferenceKeys.SHOW_CAPTIONS, + PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED, + PreferenceKeys.MARK_AS_SEEN, + PreferenceKeys.DM_MARK_AS_SEEN, + PreferenceKeys.CHECK_ACTIVITY, + PreferenceKeys.CHECK_UPDATES, + PreferenceKeys.SWAP_DATE_TIME_FORMAT_ENABLED, + PreferenceKeys.PREF_ENABLE_DM_NOTIFICATIONS, + PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH, + PreferenceKeys.FLAG_SECURE, + PreferenceKeys.TOGGLE_KEYWORD_FILTER, + PreferenceKeys.PREF_ENABLE_SENTRY, + PreferenceKeys.HIDE_MUTED_REELS, + PreferenceKeys.PLAY_IN_BACKGROUND, + PreferenceKeys.PREF_SHOWN_COUNT_TOOLTIP, + PreferenceKeys.PREF_SEARCH_FOCUS_KEYBOARD, + PreferenceKeys.PREF_STORY_SHOW_LIST, + PreferenceKeys.PREF_AUTO_BACKUP_ENABLED + ) + annotation class BooleanSettings + + @StringDef( + Constants.PREV_INSTALL_VERSION, + Constants.BROWSER_UA_CODE, + Constants.APP_UA_CODE, + PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER + ) + annotation class IntegerSettings + + @StringDef(PreferenceKeys.KEYWORD_FILTERS) + annotation class StringSetSettings + +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt b/app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt new file mode 100644 index 0000000..6124519 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package awais.instagrabber.utils + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import awais.instagrabber.utils.extensions.TAG +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes. + */ +class SingleLiveEvent : MutableLiveData() { + private val pending = AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + // Observe the internal MutableLiveData + super.observe(owner, { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + }) + } + + @MainThread + override fun setValue(t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/SingletonHolder.kt b/app/src/main/java/awais/instagrabber/utils/SingletonHolder.kt new file mode 100644 index 0000000..e5eaa5f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/SingletonHolder.kt @@ -0,0 +1,27 @@ +package awais.instagrabber.utils + +open class SingletonHolder(creator: (A) -> T) { + private var creator: ((A) -> T)? = creator + + @Volatile + private var instance: T? = null + + fun getInstance(arg: A): T { + val i = instance + if (i != null) { + return i + } + + return synchronized(this) { + val i2 = instance + if (i2 != null) { + i2 + } else { + val created = creator!!(arg) + instance = created + creator = null + created + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/TextUtils.kt b/app/src/main/java/awais/instagrabber/utils/TextUtils.kt new file mode 100644 index 0000000..a213417 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/TextUtils.kt @@ -0,0 +1,97 @@ +package awais.instagrabber.utils + +import android.util.Patterns +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.* +import kotlin.math.absoluteValue + +object TextUtils { + var dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(Constants.defaultDateTimeFormat) + + @JvmStatic + fun isEmpty(charSequence: CharSequence?): Boolean { + if (charSequence.isNullOrBlank()) return true + if (charSequence is String) { + var str = charSequence + if ("" == str || "null" == str || str.isEmpty()) return true + str = str.trim { it <= ' ' } + return "" == str || "null" == str || str.isEmpty() + } + return "null".contentEquals(charSequence) || "".contentEquals(charSequence) + } + + @JvmStatic + @JvmOverloads + fun millisToTimeString(millis: Long, includeHoursAlways: Boolean = false): String { + val sec = (millis / 1000).toInt() % 60 + var min = (millis / (1000 * 60)).toInt() + if (min >= 60) { + min = (millis / (1000 * 60) % 60).toInt() + val hr = (millis / (1000 * 60 * 60) % 24).toInt() + return String.format(Locale.ENGLISH, "%02d:%02d:%02d", hr, min, sec) + } + return if (includeHoursAlways) { + String.format(Locale.ENGLISH, "%02d:%02d:%02d", 0, min, sec) + } else String.format(Locale.ENGLISH, "%02d:%02d", min, sec) + } + + private val timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) + + @JvmStatic + fun getRelativeDateTimeString(from: Long): String { + val now = LocalDateTime.now() + val then = LocalDateTime.ofInstant(Instant.ofEpochMilli(from), ZoneId.systemDefault()) + val days = Duration.between(now, then).toDays().absoluteValue + return then.format(if (days == 0L) timeFormatter else dateFormatter) + } + + @JvmStatic + fun extractUrls(text: String): List { + if (isEmpty(text)) return emptyList() + val matcher = Patterns.WEB_URL.matcher(text) + val urls: MutableList = ArrayList() + while (matcher.find()) { + urls.add(matcher.group()) + } + return urls + } + + // https://github.com/notslang/instagram-id-to-url-segment + @JvmStatic + fun shortcodeToId(shortcode: String): Long { + var result = 0L + var i = 0 + while (i < shortcode.length && i < 11) { + val c = shortcode[i] + val k = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".indexOf(c) + result = result * 64 + k + i++ + } + return result + } + + @JvmStatic + fun setFormatter(datetimeParser: DateTimeFormatter) { + if (!DateUtils.checkFormatterValid(datetimeParser)) return + this.dateTimeFormatter = datetimeParser + } + + @JvmStatic + fun epochSecondToString(epochSecond: Long): String { + return LocalDateTime.ofInstant( + Instant.ofEpochSecond(epochSecond), + ZoneId.systemDefault() + ).format(dateTimeFormatter) + } + + @JvmStatic + fun nowToString(): String { + return LocalDateTime.now().format(dateTimeFormatter) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/ThemeUtils.kt b/app/src/main/java/awais/instagrabber/utils/ThemeUtils.kt new file mode 100644 index 0000000..a460694 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ThemeUtils.kt @@ -0,0 +1,44 @@ +@file:JvmName("ThemeUtils") + +package awais.instagrabber.utils + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import awais.instagrabber.R + +object ThemeUtils { + fun changeTheme(context: Context) { + var themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM // this is fallback / default + if (Utils.settingsHelper != null) themeCode = Utils.settingsHelper.getThemeCode(false) + if (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && Build.VERSION.SDK_INT < 29) { + themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + } + val isNight = isNight(context, themeCode) + val themeResName = + if (isNight) Utils.settingsHelper.getString(Constants.PREF_DARK_THEME) else Utils.settingsHelper.getString( + Constants.PREF_LIGHT_THEME + ) + val themeResId = context.resources.getIdentifier(themeResName, "style", context.packageName) + val finalThemeResId: Int + finalThemeResId = if (themeResId <= 0) { + // Nothing set in settings + if (isNight) R.style.AppTheme_Dark_Black else R.style.AppTheme_Light_White + } else themeResId + // Log.d(TAG, "changeTheme: finalThemeResId: " + finalThemeResId); + context.setTheme(finalThemeResId) + } + + fun isNight(context: Context, themeCode: Int): Boolean { + // check if setting is set to 'Dark' + var isNight = themeCode == AppCompatDelegate.MODE_NIGHT_YES + // if not dark check if themeCode is MODE_NIGHT_FOLLOW_SYSTEM or MODE_NIGHT_AUTO_BATTERY + if (!isNight && (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM || themeCode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY)) { + // check if resulting theme would be NIGHT + val uiMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + isNight = uiMode == Configuration.UI_MODE_NIGHT_YES + } + return isNight + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/UpdateCheckCommon.kt b/app/src/main/java/awais/instagrabber/utils/UpdateCheckCommon.kt new file mode 100644 index 0000000..3527c1f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/UpdateCheckCommon.kt @@ -0,0 +1,36 @@ +@file:JvmName("UpdateCheckCommon") + +package awais.instagrabber.utils + +import android.content.Context +import android.content.DialogInterface +import awais.instagrabber.BuildConfig +import awais.instagrabber.R +import awais.instagrabber.utils.AppExecutors.mainThread +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +fun shouldShowUpdateDialog( + force: Boolean, + version: String +): Boolean { + val skippedVersion = Utils.settingsHelper.getString(Constants.SKIPPED_VERSION) + return force || !BuildConfig.DEBUG && skippedVersion != version +} + +fun showUpdateDialog( + context: Context, + version: String, + onDownloadClickListener: DialogInterface.OnClickListener +) { + mainThread.execute { + MaterialAlertDialogBuilder(context).apply { + setTitle(context.getString(R.string.update_available, version)) + setNeutralButton(R.string.skip_update) { dialog: DialogInterface, _: Int -> + Utils.settingsHelper.putString(Constants.SKIPPED_VERSION, version) + dialog.dismiss() + } + setPositiveButton(R.string.action_download, onDownloadClickListener) + setNegativeButton(R.string.cancel, null) + }.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/UserAgentUtils.kt b/app/src/main/java/awais/instagrabber/utils/UserAgentUtils.kt new file mode 100644 index 0000000..4eb05fc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/UserAgentUtils.kt @@ -0,0 +1,82 @@ +@file:JvmName("UserAgentUtils") + +package awais.instagrabber.utils + +/* GraphQL user agents (which are just standard browser UA"s). + * Go to https://www.whatismybrowser.com/guides/the-latest-user-agent/ to update it + * Windows first (Assume win64 not wow64): Chrome, Firefox, Edge + * Then macOS: Chrome, Firefox, Safari + */ +@JvmField +val browsers = arrayOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Edg/90.0.818.62", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11.3; rv:88.0) Gecko/20100101 Firefox/88.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15" +) + +// use APKpure, assume arm64-v8a +private const val igVersion = "195.0.0.31.123" +private const val igVersionCode = "302733772" + +// you can pick *any* device as long as you LEAVE OUT the resolution for maximum download quality +// https://github.com/dilame/instagram-private-api/blob/master/src/samples/devices.json +@JvmField +val devices = arrayOf( + "25/7.1.1; 440dpi; 2880x5884; Xiaomi; Mi Note 3; jason; qcom", + "23/6.0.1; 480dpi; 2880x5884; Xiaomi; Redmi Note 3; kenzo; qcom", + "23/6.0; 480dpi; 2880x5884; Xiaomi; Redmi Note 4; nikel; mt6797", + "24/7.0; 480dpi; 2880x5884; Xiaomi/xiaomi; Redmi Note 4; mido; qcom", + "23/6.0; 480dpi; 2880x5884; Xiaomi; Redmi Note 4X; nikel; mt6797", + "27/8.1.0; 440dpi; 2880x5884; Xiaomi/xiaomi; Redmi Note 5; whyred; qcom", + "23/6.0.1; 480dpi; 2880x5884; Xiaomi; Redmi 4; markw; qcom", + "27/8.1.0; 440dpi; 2880x5884; Xiaomi/xiaomi; Redmi 5 Plus; vince; qcom", + "25/7.1.2; 440dpi; 2880x5884; Xiaomi/xiaomi; Redmi 5 Plus; vince; qcom", + "26/8.0.0; 480dpi; 2880x5884; Xiaomi; MI 5; gemini; qcom", + "27/8.1.0; 480dpi; 2880x5884; Xiaomi/xiaomi; Mi A1; tissot_sprout; qcom", + "26/8.0.0; 480dpi; 2880x5884; Xiaomi; MI 6; sagit; qcom", + "25/7.1.1; 440dpi; 2880x5884; Xiaomi; MI MAX 2; oxygen; qcom", + "24/7.0; 480dpi; 2880x5884; Xiaomi; MI 5s; capricorn; qcom", + "26/8.0.0; 480dpi; 2880x5884; samsung; SM-A520F; a5y17lte; samsungexynos7880", + "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G950F; dreamlte; samsungexynos8895", + "26/8.0.0; 640dpi; 2880x5884; samsung; SM-G950F; dreamlte; samsungexynos8895", + "26/8.0.0; 420dpi; 2880x5884; samsung; SM-G955F; dream2lte; samsungexynos8895", + "26/8.0.0; 560dpi; 2880x5884; samsung; SM-G955F; dream2lte; samsungexynos8895", + "24/7.0; 480dpi; 2880x5884; samsung; SM-A510F; a5xelte; samsungexynos7580", + "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G930F; herolte; samsungexynos8890", + "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G935F; hero2lte; samsungexynos8890", + "26/8.0.0; 420dpi; 2880x5884; samsung; SM-G965F; star2lte; samsungexynos9810", + "26/8.0.0; 480dpi; 2880x5884; samsung; SM-A530F; jackpotlte; samsungexynos7885", + "24/7.0; 640dpi; 2880x5884; samsung; SM-G925F; zerolte; samsungexynos7420", + "26/8.0.0; 420dpi; 2880x5884; samsung; SM-A720F; a7y17lte; samsungexynos7880", + "24/7.0; 640dpi; 2880x5884; samsung; SM-G920F; zeroflte; samsungexynos7420", + "24/7.0; 420dpi; 2880x5884; samsung; SM-J730FM; j7y17lte; samsungexynos7870", + "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G960F; starlte; samsungexynos9810", + "26/8.0.0; 420dpi; 2880x5884; samsung; SM-N950F; greatlte; samsungexynos8895", + "26/8.0.0; 420dpi; 2880x5884; samsung; SM-A730F; jackpot2lte; samsungexynos7885", + "26/8.0.0; 420dpi; 2880x5884; samsung; SM-A605FN; a6plte; qcom", + "26/8.0.0; 480dpi; 2880x5884; HUAWEI/HONOR; STF-L09; HWSTF; hi3660", + "27/8.1.0; 480dpi; 2880x5884; HUAWEI/HONOR; COL-L29; HWCOL; kirin970", + "26/8.0.0; 480dpi; 2880x5884; HUAWEI/HONOR; LLD-L31; HWLLD-H; hi6250", + "26/8.0.0; 480dpi; 2880x5884; HUAWEI; ANE-LX1; HWANE; hi6250", + "26/8.0.0; 480dpi; 2880x5884; HUAWEI; FIG-LX1; HWFIG-H; hi6250", + "27/8.1.0; 480dpi; 2880x5884; HUAWEI/HONOR; COL-L29; HWCOL; kirin970", + "26/8.0.0; 480dpi; 2880x5884; HUAWEI/HONOR; BND-L21; HWBND-H; hi6250", + "23/6.0.1; 420dpi; 2880x5884; LeMobile/LeEco; Le X527; le_s2_ww; qcom", // https://github.com/mimmi20/BrowserDetector/tree/master + "28/9; 560dpi; 2880x5884; samsung; SM-N960F; crownlte; samsungexynos9810", // mgp25 + "23/6.0.1; 640dpi; 2880x5884; LGE/lge; RS988; h1; h1", + "24/7.0; 640dpi; 2880x5884; HUAWEI; LON-L29; HWLON; hi3660", + "23/6.0.1; 640dpi; 2880x5884; ZTE; ZTE A2017U; ailsa_ii; qcom", + "23/6.0.1; 640dpi; 2880x5884; samsung; SM-G935F; hero2lte; samsungexynos8890", + "23/6.0.1; 640dpi; 2880x5884; samsung; SM-G930F; herolte; samsungexynos8890" +) + +fun generateBrowserUA(code: Int): String { + return browsers[code] +} + +fun generateAppUA(code: Int, lang: String): String { + return "Instagram " + igVersion + " Android (" + devices[code] + "; " + lang + "; " + igVersionCode + ")" +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/Utils.java b/app/src/main/java/awais/instagrabber/utils/Utils.java new file mode 100644 index 0000000..78f30ee --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/Utils.java @@ -0,0 +1,554 @@ +package awais.instagrabber.utils; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.storage.StorageManager; +import android.provider.Browser; +import android.provider.DocumentsContract; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Pair; +import android.util.TypedValue; +import android.view.Display; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.documentfile.provider.DocumentFile; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; + +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +import org.json.JSONObject; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import awais.instagrabber.R; +import awais.instagrabber.models.PostsLayoutPreferences; +import awais.instagrabber.models.enums.FavoriteType; + +public final class Utils { + private static final String TAG = "Utils"; + private static final int VIDEO_CACHE_MAX_BYTES = 10 * 1024 * 1024; + + // public static LogCollector logCollector; + public static SettingsHelper settingsHelper; + public static boolean sessionVolumeFull = false; + public static final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + public static final DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); + public static ClipboardManager clipboardManager; + public static SimpleCache simpleCache; + private static int statusBarHeight; + private static int actionBarHeight; + public static String cacheDir; + private static int defaultStatusBarColor; + private static Object[] volumes; + + public static int convertDpToPx(final float dp) { + return Math.round((dp * displayMetrics.densityDpi) / DisplayMetrics.DENSITY_DEFAULT); + } + + public static void copyText(@NonNull final Context context, final CharSequence string) { + if (clipboardManager == null) { + clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + } + int toastMessage = R.string.clipboard_error; + if (clipboardManager != null) { + try { + clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.app_name), string)); + toastMessage = R.string.clipboard_copied; + } catch (Exception e) { + Log.e(TAG, "copyText: ", e); + } + } + Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show(); + } + + public static Map sign(final Map form) { + // final String signed = sign(Constants.SIGNATURE_KEY, new JSONObject(form).toString()); + // if (signed == null) { + // return null; + // } + final Map map = new HashMap<>(); + // map.put("ig_sig_key_version", Constants.SIGNATURE_VERSION); + // map.put("signed_body", signed); + map.put("signed_body", "SIGNATURE." + new JSONObject(form).toString()); + return map; + } + + // public static String sign(final String key, final String message) { + // try { + // final Mac hasher = Mac.getInstance("HmacSHA256"); + // hasher.init(new SecretKeySpec(key.getBytes(), "HmacSHA256")); + // byte[] hash = hasher.doFinal(message.getBytes()); + // final StringBuilder hexString = new StringBuilder(); + // for (byte b : hash) { + // final String hex = Integer.toHexString(0xff & b); + // if (hex.length() == 1) hexString.append('0'); + // hexString.append(hex); + // } + // return hexString.toString() + "." + message; + // } catch (Exception e) { + // Log.e(TAG, "Error signing", e); + // return null; + // } + // } + + public static String getMimeType(@NonNull final Uri uri, final ContentResolver contentResolver) { + String mimeType; + final String scheme = uri.getScheme(); + final String fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + if (TextUtils.isEmpty(scheme)) { + mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension.toLowerCase()); + } else { + if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { + mimeType = contentResolver.getType(uri); + } else { + mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension.toLowerCase()); + } + } + if (mimeType == null) return null; + return mimeType.toLowerCase(); + } + + public static SimpleCache getSimpleCacheInstance(final Context context) { + if (context == null) { + return null; + } + final ExoDatabaseProvider exoDatabaseProvider = new ExoDatabaseProvider(context); + final File cacheDir = context.getCacheDir(); + if (simpleCache == null && cacheDir != null) { + simpleCache = new SimpleCache(cacheDir, new LeastRecentlyUsedCacheEvictor(VIDEO_CACHE_MAX_BYTES), exoDatabaseProvider); + } + return simpleCache; + } + + @Nullable + public static Pair migrateOldFavQuery(final String queryText) { + if (queryText.startsWith("@")) { + return new Pair<>(FavoriteType.USER, queryText.substring(1)); + } else if (queryText.contains("/")) { + return new Pair<>(FavoriteType.LOCATION, queryText.substring(0, queryText.indexOf("/"))); + } else if (queryText.startsWith("#")) { + return new Pair<>(FavoriteType.HASHTAG, queryText.substring(1)); + } + return null; + } + + public static int getStatusBarHeight(final Context context) { + if (statusBarHeight > 0) { + return statusBarHeight; + } + int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android"); + if (resourceId > 0) { + statusBarHeight = context.getResources().getDimensionPixelSize(resourceId); + } + return statusBarHeight; + } + + public static int getActionBarHeight(@NonNull final Context context) { + if (actionBarHeight > 0) { + return actionBarHeight; + } + final TypedValue tv = new TypedValue(); + if (context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + actionBarHeight = TypedValue.complexToDimensionPixelSize(tv.data, displayMetrics); + } + return actionBarHeight; + } + + public static void openURL(final Context context, final String url) { + if (context == null || TextUtils.isEmpty(url)) { + return; + } + final Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + i.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); + i.putExtra(Browser.EXTRA_CREATE_NEW_TAB, true); + try { + context.startActivity(i); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "openURL: No activity found to handle URLs", e); + Toast.makeText(context, context.getString(R.string.no_external_app_url), Toast.LENGTH_LONG).show(); + } catch (Exception e) { + Log.e(TAG, "openURL", e); + } + } + + public static void openEmailAddress(final Context context, final String emailAddress) { + if (context == null || TextUtils.isEmpty(emailAddress)) { + return; + } + Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:" + emailAddress)); + emailIntent.putExtra(Intent.EXTRA_SUBJECT, ""); + emailIntent.putExtra(Intent.EXTRA_TEXT, ""); + context.startActivity(emailIntent); + } + + public static void displayToastAboveView(@NonNull final Context context, + @NonNull final View view, + @NonNull final String text) { + final Toast toast = Toast.makeText(context, text, Toast.LENGTH_SHORT); + toast.setGravity(Gravity.TOP | Gravity.START, + view.getLeft(), + view.getTop()); + toast.show(); + } + + public static PostsLayoutPreferences getPostsLayoutPreferences(final String layoutPreferenceKey) { + PostsLayoutPreferences layoutPreferences = PostsLayoutPreferences.fromJson(settingsHelper.getString(layoutPreferenceKey)); + if (layoutPreferences == null) { + layoutPreferences = PostsLayoutPreferences.builder().build(); + settingsHelper.putString(layoutPreferenceKey, layoutPreferences.getJson()); + } + return layoutPreferences; + } + + private static Field mAttachInfoField; + private static Field mStableInsetsField; + + public static int getViewInset(View view) { + if (view == null + || view.getHeight() == displayMetrics.heightPixels + || view.getHeight() == displayMetrics.widthPixels - getStatusBarHeight(view.getContext())) { + return 0; + } + try { + if (mAttachInfoField == null) { + //noinspection JavaReflectionMemberAccess + mAttachInfoField = View.class.getDeclaredField("mAttachInfo"); + mAttachInfoField.setAccessible(true); + } + Object mAttachInfo = mAttachInfoField.get(view); + if (mAttachInfo != null) { + if (mStableInsetsField == null) { + mStableInsetsField = mAttachInfo.getClass().getDeclaredField("mStableInsets"); + mStableInsetsField.setAccessible(true); + } + Rect insets = (Rect) mStableInsetsField.get(mAttachInfo); + if (insets == null) { + return 0; + } + return insets.bottom; + } + } catch (Exception e) { + Log.e(TAG, "getViewInset", e); + } + return 0; + } + + public static int getThemeAccentColor(Context context) { + int colorAttr; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + colorAttr = android.R.attr.colorAccent; + } else { + //Get colorAccent defined for AppCompat + colorAttr = context.getResources().getIdentifier("colorAccent", "attr", context.getPackageName()); + } + TypedValue outValue = new TypedValue(); + context.getTheme().resolveAttribute(colorAttr, outValue, true); + return outValue.data; + } + + public static int getAttrValue(@NonNull final Context context, final int attr) { + final TypedValue outValue = new TypedValue(); + context.getTheme().resolveAttribute(attr, outValue, true); + return outValue.data; + } + + public static int getAttrResId(@NonNull final Context context, final int attr) { + final TypedValue outValue = new TypedValue(); + context.getTheme().resolveAttribute(attr, outValue, true); + return outValue.resourceId; + } + + public static void transparentStatusBar(final Activity activity, + final boolean enable, + final boolean fullscreen) { + if (activity == null) return; + final ActionBar actionBar = ((AppCompatActivity) activity).getSupportActionBar(); + final Window window = activity.getWindow(); + final View decorView = window.getDecorView(); + if (enable) { + decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + if (actionBar != null) { + actionBar.hide(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + defaultStatusBarColor = window.getStatusBarColor(); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + // FOR TRANSPARENT NAVIGATION BAR + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + window.setStatusBarColor(Color.TRANSPARENT); + Log.d(TAG, "Setting Color Transparent " + Color.TRANSPARENT + " Default Color " + defaultStatusBarColor); + return; + } + Log.d(TAG, "Setting Color Trans " + Color.TRANSPARENT); + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + return; + } + if (fullscreen) { + int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN; + decorView.setSystemUiVisibility(uiOptions); + return; + } + if (actionBar != null) { + actionBar.show(); + } + decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.setStatusBarColor(defaultStatusBarColor); + return; + } + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + + // public static void mediaScanFile(@NonNull final Context context, + // @NonNull File file, + // @NonNull final OnScanCompletedListener callback) { + // //noinspection UnstableApiUsage + // final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(Files.getFileExtension(file.getName())); + // MediaScannerConnection.scanFile( + // context, + // new String[]{file.getAbsolutePath()}, + // new String[]{mimeType}, + // callback + // ); + // } + + public static void showKeyboard(@NonNull final View view) { + try { + final Context context = view.getContext(); + if (context == null) return; + final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + view.requestFocus(); + final boolean shown = imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); + if (!shown) { + Log.e(TAG, "showKeyboard: System did not display the keyboard"); + } + } catch (Exception e) { + Log.e(TAG, "showKeyboard: ", e); + } + } + + public static void hideKeyboard(final View view) { + if (view == null) return; + final Context context = view.getContext(); + if (context == null) return; + try { + final InputMethodManager manager = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE); + if (manager == null) return; + manager.hideSoftInputFromWindow(view.getWindowToken(), 0); + } catch (Exception e) { + Log.e(TAG, "hideKeyboard: ", e); + } + } + + public static Drawable getAnimatableDrawable(@NonNull final Context context, + @DrawableRes final int drawableResId) { + final Drawable drawable; + if (Build.VERSION.SDK_INT >= 24) { + drawable = ContextCompat.getDrawable(context, drawableResId); + } else { + drawable = AnimatedVectorDrawableCompat.create(context, drawableResId); + } + return drawable; + } + + public static void enabledKeepScreenOn(@NonNull final Activity activity) { + final Window window = activity.getWindow(); + if (window == null) return; + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + public static void disableKeepScreenOn(@NonNull final Activity activity) { + final Window window = activity.getWindow(); + if (window == null) return; + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + public static void moveItem(int sourceIndex, int targetIndex, List list) { + if (sourceIndex <= targetIndex) { + Collections.rotate(list.subList(sourceIndex, targetIndex + 1), -1); + } else { + Collections.rotate(list.subList(targetIndex, sourceIndex + 1), 1); + } + } + + // public static void scanDocumentFile(@NonNull final Context context, + // @NonNull final DocumentFile documentFile, + // @NonNull final OnScanCompletedListener callback) { + // if (!documentFile.isFile() || !documentFile.exists()) { + // Log.d(TAG, "scanDocumentFile: " + documentFile); + // callback.onScanCompleted(null, null); + // return; + // } + // File file = null; + // try { + // file = getDocumentFileRealPath(context, documentFile); + // } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + // Log.e(TAG, "scanDocumentFile: ", e); + // } + // if (file == null) return; + // MediaScannerConnection.scanFile(context, + // new String[]{file.getAbsolutePath()}, + // new String[]{documentFile.getType()}, + // callback); + // } + + public static File getDocumentFileRealPath(@NonNull final Context context, + @NonNull final DocumentFile documentFile) + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + final String docId = DocumentsContract.getDocumentId(documentFile.getUri()); + final String[] split = docId.split(":"); + final String type = split[0]; + + if (type.equalsIgnoreCase("primary")) { + return new File(Environment.getExternalStorageDirectory(), split[1]); + } else if (type.equalsIgnoreCase("raw")) { + return new File(split[1]); + } else { + if (volumes == null) { + final StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + if (sm == null) return null; + final Method getVolumeListMethod = sm.getClass().getMethod("getVolumeList"); + volumes = (Object[]) getVolumeListMethod.invoke(sm); + } + if (volumes == null) return null; + for (Object volume : volumes) { + final Method getUuidMethod = volume.getClass().getMethod("getUuid"); + final String uuid = (String) getUuidMethod.invoke(volume); + + if (uuid != null && uuid.equalsIgnoreCase(type)) { + final Method getPathMethod = volume.getClass().getMethod("getPath"); + final String path = (String) getPathMethod.invoke(volume); + return new File(path, split[1]); + } + } + } + + return null; + } + + public static void setupSelectedDir(@NonNull final Context context, + @NonNull final Intent intent) throws DownloadUtils.ReselectDocumentTreeException { + final Uri dirUri = intent.getData(); + Log.d(TAG, "onActivityResult: " + dirUri); + if (dirUri == null) return; + final int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + context.getContentResolver().takePersistableUriPermission(dirUri, takeFlags); + // re-init DownloadUtils + DownloadUtils.init(context, dirUri.toString()); + } + + @NonNull + public static Point getNavigationBarSize(@NonNull Context context) { + Point appUsableSize = getAppUsableScreenSize(context); + Point realScreenSize = getRealScreenSize(context); + + // navigation bar on the right + if (appUsableSize.x < realScreenSize.x) { + return new Point(realScreenSize.x - appUsableSize.x, appUsableSize.y); + } + + // navigation bar at the bottom + if (appUsableSize.y < realScreenSize.y) { + return new Point(appUsableSize.x, realScreenSize.y - appUsableSize.y); + } + + // navigation bar is not present + return new Point(); + } + + @NonNull + public static Point getAppUsableScreenSize(@NonNull Context context) { + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = windowManager.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + return size; + } + + @NonNull + public static Point getRealScreenSize(@NonNull Context context) { + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = windowManager.getDefaultDisplay(); + Point size = new Point(); + display.getRealSize(size); + return size; + } + + public static LiveData> zipLiveData(@NonNull final LiveData firstLiveData, + @NonNull final LiveData secondLiveData) { + final ZippedLiveData zippedLiveData = new ZippedLiveData<>(); + zippedLiveData.addFirstSource(firstLiveData); + zippedLiveData.addSecondSource(secondLiveData); + return zippedLiveData; + } + + public static class ZippedLiveData extends MediatorLiveData> { + private F lastF; + private S lastS; + + private void update() { + F localLastF = lastF; + S localLastS = lastS; + if (localLastF != null && localLastS != null) { + setValue(new Pair<>(localLastF, localLastS)); + } + } + + public void addFirstSource(@NonNull final LiveData firstLiveData) { + addSource(firstLiveData, f -> { + lastF = f; + update(); + }); + } + + public void addSecondSource(@NonNull final LiveData secondLiveData) { + addSource(secondLiveData, s -> { + lastS = s; + update(); + }); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/ViewUtils.kt b/app/src/main/java/awais/instagrabber/utils/ViewUtils.kt new file mode 100644 index 0000000..d479cf9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/ViewUtils.kt @@ -0,0 +1,117 @@ +@file:JvmName("ViewUtils") + +package awais.instagrabber.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape +import android.os.Build +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.content.res.ResourcesCompat +import androidx.core.util.Pair +import androidx.dynamicanimation.animation.FloatPropertyCompat +import androidx.dynamicanimation.animation.SpringAnimation +import kotlin.jvm.internal.Intrinsics + +fun createRoundRectDrawableWithIcon(context: Context, rad: Int, iconRes: Int): Drawable? { + val defaultDrawable = ShapeDrawable(RoundRectShape(FloatArray(8) { rad.toFloat() }, null, null)) + defaultDrawable.paint.color = -0x1 + val d = ResourcesCompat.getDrawable(context.resources, iconRes, null) ?: return null + val drawable = d.mutate() + return CombinedDrawable(defaultDrawable, drawable) +} + +fun createRoundRectDrawable(rad: Int, defaultColor: Int): Drawable { + val defaultDrawable = ShapeDrawable(RoundRectShape(FloatArray(8) { rad.toFloat() }, null, null)) + defaultDrawable.paint.color = defaultColor + return defaultDrawable +} + +fun createFrame( + width: Int, + height: Float, + gravity: Int, + leftMargin: Float, + topMargin: Float, + rightMargin: Float, + bottomMargin: Float +): FrameLayout.LayoutParams { + val layoutParams = FrameLayout.LayoutParams(getSize(width.toFloat()), getSize(height), gravity) + layoutParams.setMargins( + Utils.convertDpToPx(leftMargin), Utils.convertDpToPx(topMargin), Utils.convertDpToPx(rightMargin), + Utils.convertDpToPx(bottomMargin) + ) + return layoutParams +} + +fun createGradientDrawable( + orientation: GradientDrawable.Orientation?, + @ColorInt colors: IntArray? +): GradientDrawable { + val drawable = GradientDrawable(orientation, colors) + drawable.shape = GradientDrawable.RECTANGLE + return drawable +} + +private fun getSize(size: Float): Int { + return if (size < 0) size.toInt() else Utils.convertDpToPx(size) +} + +fun measure(view: View, parent: View): Pair { + view.measure( + View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) + ) + return Pair(view.measuredHeight, view.measuredWidth) +} + +fun getTextViewValueWidth(textView: TextView, text: String?): Float { + return textView.paint.measureText(text) +} + +/** + * Creates [SpringAnimation] for object. + * If finalPosition is not [Float.NaN] then create [SpringAnimation] with + * [SpringForce.mFinalPosition]. + * + * @param object Object + * @param property object's property to be animated. + * @param finalPosition [SpringForce.mFinalPosition] Final position of spring. + * @return [SpringAnimation] + */ +fun springAnimationOf( + `object`: Any?, + property: FloatPropertyCompat?, + finalPosition: Float? +): SpringAnimation { + return finalPosition?.let { SpringAnimation(`object`, property, it) } ?: SpringAnimation(`object`, property) +} + +fun suppressLayoutCompat(`$this$suppressLayoutCompat`: ViewGroup, suppress: Boolean) { + Intrinsics.checkNotNullParameter(`$this$suppressLayoutCompat`, "\$this\$suppressLayoutCompat") + if (Build.VERSION.SDK_INT >= 29) { + `$this$suppressLayoutCompat`.suppressLayout(suppress) + } else { + hiddenSuppressLayout(`$this$suppressLayoutCompat`, suppress) + } +} + +private var tryHiddenSuppressLayout = true + +@SuppressLint("NewApi") +private fun hiddenSuppressLayout(group: ViewGroup, suppress: Boolean) { + if (tryHiddenSuppressLayout) { + try { + group.suppressLayout(suppress) + } catch (var3: NoSuchMethodError) { + tryHiddenSuppressLayout = false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/VoiceRecorder.java b/app/src/main/java/awais/instagrabber/utils/VoiceRecorder.java new file mode 100644 index 0000000..266d814 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/VoiceRecorder.java @@ -0,0 +1,214 @@ +package awais.instagrabber.utils; + +import android.app.Application; +import android.content.ContentResolver; +import android.media.MediaRecorder; +import android.os.Handler; +import android.os.Message; +import android.os.ParcelFileDescriptor; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; + +import java.io.IOException; +import java.io.File; +import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class VoiceRecorder { + private static final String TAG = VoiceRecorder.class.getSimpleName(); + private static final String FILE_PREFIX = "recording"; + private static final String EXTENSION = "mp4"; + private static final String MIME_TYPE = MimeTypeMap.getSingleton().getMimeTypeFromExtension(EXTENSION); + private static final int AUDIO_SAMPLE_RATE = 44100; + private static final int AUDIO_BIT_DEPTH = 16; + private static final int AUDIO_BIT_RATE = AUDIO_SAMPLE_RATE * AUDIO_BIT_DEPTH; + private static final String FILE_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"; + private static final DateTimeFormatter SIMPLE_DATE_FORMAT = DateTimeFormatter.ofPattern(FILE_FORMAT, Locale.US); + + private final List waveform = new ArrayList<>(); + private final DocumentFile recordingsDir; + private final VoiceRecorderCallback callback; + + private MediaRecorder recorder; + private DocumentFile audioTempFile; + private MaxAmpHandler maxAmpHandler; + private boolean stopped; + + public VoiceRecorder(@NonNull final DocumentFile recordingsDir, final VoiceRecorderCallback callback) { + this.recordingsDir = recordingsDir; + this.callback = callback; + } + + public void startRecording(final ContentResolver contentResolver) { + stopped = false; + ParcelFileDescriptor parcelFileDescriptor = null; + try { + recorder = new MediaRecorder(); + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + deleteTempAudioFile(); + audioTempFile = getAudioRecordFile(); + parcelFileDescriptor = contentResolver.openFileDescriptor(audioTempFile.getUri(), "rwt"); + recorder.setOutputFile(parcelFileDescriptor.getFileDescriptor()); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC); + recorder.setAudioEncodingBitRate(AUDIO_BIT_RATE); + recorder.setAudioSamplingRate(AUDIO_SAMPLE_RATE); + recorder.prepare(); + waveform.clear(); + maxAmpHandler = new MaxAmpHandler(waveform); + recorder.start(); + if (callback != null) { + callback.onStart(); + } + getMaxAmp(); + } catch (Exception e) { + Log.e(TAG, "Audio recording failed", e); + deleteTempAudioFile(); + } finally { + if (parcelFileDescriptor != null) { + try { + parcelFileDescriptor.close(); + } catch (IOException ignored) {} + } + } + } + + public void stopRecording(final boolean cancelled) { + stopped = true; + if (maxAmpHandler != null) { + maxAmpHandler.removeCallbacks(getMaxAmpRunnable); + } + if (recorder == null) { + if (callback != null) { + callback.onCancel(); + } + return; + } + try { + recorder.stop(); + recorder.release(); + recorder = null; + // processWaveForm(); + } catch (Exception e) { + Log.e(TAG, "stopRecording: error", e); + deleteTempAudioFile(); + } + if (cancelled) { + deleteTempAudioFile(); + if (callback != null) { + callback.onCancel(); + } + return; + } + if (callback != null) { + callback.onComplete(new VoiceRecordingResult(MIME_TYPE, audioTempFile, waveform)); + } + } + + private static class MaxAmpHandler extends Handler { + private final List waveform; + + public MaxAmpHandler(final List waveform) { + this.waveform = waveform; + } + + @Override + public void handleMessage(@NonNull final Message msg) { + if (waveform == null) return; + waveform.add(msg.obj instanceof Float ? (Float) msg.obj : 0f); + } + } + + private final Runnable getMaxAmpRunnable = this::getMaxAmp; + + private void getMaxAmp() { + if (stopped || recorder == null || maxAmpHandler == null) return; + final float value = (float) Math.pow(2.0d, (Math.log10((double) recorder.getMaxAmplitude() / 2700.0d) * 20.0d) / 6.0d); + maxAmpHandler.postDelayed(getMaxAmpRunnable, 100); + Message msg = Message.obtain(); + msg.obj = value; + maxAmpHandler.sendMessage(msg); + } + + // private void processWaveForm() { + // // if (waveform == null || waveform.isEmpty()) return; + // final Optional maxAmplitudeOptional = waveform.stream().max(Float::compareTo); + // if (!maxAmplitudeOptional.isPresent()) return; + // final float maxAmp = maxAmplitudeOptional.get(); + // final List normalised = waveform.stream() + // .map(amp -> amp / maxAmp) + // .map(amp -> amp < 0.01f ? 0f : amp) + // .collect(Collectors.toList()); + // // final List normalised = waveform.stream() + // // .map(amp -> amp * 1.0f / 32768) + // // .collect(Collectors.toList()); + // // Log.d(TAG, "processWaveForm: " + waveform); + // Log.d(TAG, "processWaveForm: " + normalised); + // } + + @NonNull + private DocumentFile getAudioRecordFile() { + final String name = String.format("%s-%s.%s", FILE_PREFIX, LocalDateTime.now().format(SIMPLE_DATE_FORMAT), EXTENSION); + DocumentFile file = recordingsDir.findFile(name); + if (file == null || !file.exists()) { + file = recordingsDir.createFile(MIME_TYPE, name); + } + return file; + } + + private void deleteTempAudioFile() { + if (audioTempFile == null) { + //noinspection ResultOfMethodCallIgnored + getAudioRecordFile().delete(); + return; + } + final boolean deleted = audioTempFile.delete(); + if (!deleted) { + Log.w(TAG, "stopRecording: file not deleted"); + } + audioTempFile = null; + } + + public static class VoiceRecordingResult { + private final String mimeType; + private final DocumentFile file; + private final List waveform; + private final int samplingFreq = 10; + + public VoiceRecordingResult(final String mimeType, final DocumentFile file, final List waveform) { + this.mimeType = mimeType; + this.file = file; + this.waveform = waveform; + } + + public String getMimeType() { + return mimeType; + } + + public DocumentFile getFile() { + return file; + } + + public List getWaveform() { + return waveform; + } + + public int getSamplingFreq() { + return samplingFreq; + } + } + + public interface VoiceRecorderCallback { + void onStart(); + + void onComplete(final VoiceRecordingResult voiceRecordingResult); + + void onCancel(); + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/emoji/EmojiCategoryDeserializer.kt b/app/src/main/java/awais/instagrabber/utils/emoji/EmojiCategoryDeserializer.kt new file mode 100644 index 0000000..32c2ba9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/emoji/EmojiCategoryDeserializer.kt @@ -0,0 +1,44 @@ +package awais.instagrabber.utils.emoji + +import android.util.Log +import awais.instagrabber.customviews.emoji.Emoji +import awais.instagrabber.customviews.emoji.EmojiCategory +import awais.instagrabber.customviews.emoji.EmojiCategoryType +import awais.instagrabber.utils.extensions.TAG +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type + +class EmojiCategoryDeserializer : JsonDeserializer { + + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): EmojiCategory { + val jsonObject = json.asJsonObject + val typeElement = jsonObject["type"] + val emojisObject = jsonObject.getAsJsonObject("emojis") + if (typeElement == null || emojisObject == null) { + throw JsonParseException("Invalid json for EmojiCategory") + } + val typeString = typeElement.asString + val type: EmojiCategoryType = try { + EmojiCategoryType.valueOf(typeString) + } catch (e: IllegalArgumentException) { + Log.e(TAG, "deserialize: ", e) + EmojiCategoryType.OTHERS + } + val emojis: MutableMap = linkedMapOf() + for ((unicode, value) in emojisObject.entrySet()) { + if (unicode == null || value == null) { + throw JsonParseException("Invalid json for EmojiCategory") + } + emojis[unicode] = context.deserialize(value, Emoji::class.java) + } + return EmojiCategory(type, emojis) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/emoji/EmojiDeserializer.kt b/app/src/main/java/awais/instagrabber/utils/emoji/EmojiDeserializer.kt new file mode 100644 index 0000000..3674b10 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/emoji/EmojiDeserializer.kt @@ -0,0 +1,40 @@ +package awais.instagrabber.utils.emoji + +import awais.instagrabber.customviews.emoji.Emoji +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type + +class EmojiDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Emoji { + val jsonObject = json.asJsonObject + val unicodeElement = jsonObject["unicode"] + val nameElement = jsonObject["name"] + if (unicodeElement == null || nameElement == null) { + throw JsonParseException("Invalid json for Emoji class") + } + val variantsElement = jsonObject["variants"] + val variants: MutableList = mutableListOf() + if (variantsElement != null) { + val variantsArray = variantsElement.asJsonArray + for (variantElement in variantsArray) { + val variant = context.deserialize(variantElement, Emoji::class.java) + if (variant != null) { + variants.add(variant) + } + } + } + return Emoji( + unicodeElement.asString, + nameElement.asString, + variants + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/emoji/EmojiParser.kt b/app/src/main/java/awais/instagrabber/utils/emoji/EmojiParser.kt new file mode 100644 index 0000000..ac2f679 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/emoji/EmojiParser.kt @@ -0,0 +1,53 @@ +package awais.instagrabber.utils.emoji + +import android.content.Context +import android.util.Log +import awais.instagrabber.R +import awais.instagrabber.customviews.emoji.Emoji +import awais.instagrabber.customviews.emoji.EmojiCategory +import awais.instagrabber.customviews.emoji.EmojiCategoryType +import awais.instagrabber.utils.NetworkUtils +import awais.instagrabber.utils.SingletonHolder +import awais.instagrabber.utils.extensions.TAG +import com.google.gson.FieldNamingPolicy +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken + +class EmojiParser private constructor(context: Context) { + var allEmojis: Map = emptyMap() + var categoryMap: Map = emptyMap() + val emojiCategories: List by lazy { + categoryMap.values.toList() + } + + fun getEmoji(emoji: String): Emoji? { + return allEmojis[emoji] + } + + init { + try { + context.applicationContext.resources.openRawResource(R.raw.emojis).use { `in` -> + val json = NetworkUtils.readFromInputStream(`in`) + val gson = GsonBuilder().apply { + setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + registerTypeAdapter(EmojiCategory::class.java, EmojiCategoryDeserializer()) + registerTypeAdapter(Emoji::class.java, EmojiDeserializer()) + setLenient() + }.create() + val type = object : TypeToken>() {}.type + categoryMap = gson.fromJson(json, type) + // Log.d(TAG, "EmojiParser: " + categoryMap); + allEmojis = categoryMap + .flatMap { (_, emojiCategory) -> emojiCategory.emojis.values } + .flatMap { listOf(it) + it.variants } + .filterNotNull() + .map { it.unicode to it } + .toMap() + } + } catch (e: Exception) { + Log.e(TAG, "EmojiParser: ", e) + } + } + + companion object : SingletonHolder(::EmojiParser) +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/extensions/AnyExtensions.kt b/app/src/main/java/awais/instagrabber/utils/extensions/AnyExtensions.kt new file mode 100644 index 0000000..cd902ab --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/extensions/AnyExtensions.kt @@ -0,0 +1,12 @@ +package awais.instagrabber.utils.extensions + +val Any.TAG: String + get() { + return if (!javaClass.isAnonymousClass) { + val name = javaClass.simpleName + if (name.length <= 23) name else name.substring(0, 23) // first 23 chars + } else { + val name = javaClass.name + if (name.length <= 23) name else name.substring(name.length - 23, name.length) // last 23 chars + } + } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt b/app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt new file mode 100644 index 0000000..5d9a1cd --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.utils.extensions + +fun String.trimAll() = this.trim { it <= ' ' } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt b/app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt new file mode 100644 index 0000000..1b16afb --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.utils.extensions + +import awais.instagrabber.repositories.responses.User + +fun User.isReallyPrivate(currentUser: User? = null): Boolean { + if (currentUser == null) return this.isPrivate + if (this.pk == currentUser.pk) return false + return this.friendshipStatus?.following == false && this.isPrivate +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java new file mode 100644 index 0000000..b471c97 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java @@ -0,0 +1,90 @@ +package awais.instagrabber.viewmodels; + +import android.app.Application; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import awais.instagrabber.db.repositories.AccountRepository; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.UserRepository; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class AppStateViewModel extends AndroidViewModel { + private static final String TAG = AppStateViewModel.class.getSimpleName(); + + private final String cookie; + private final MutableLiveData> currentUser = new MutableLiveData<>(Resource.loading(null)); + + private AccountRepository accountRepository; + + private UserRepository userRepository; + + public AppStateViewModel(@NonNull final Application application) { + super(application); + // Log.d(TAG, "AppStateViewModel: constructor"); + cookie = settingsHelper.getString(Constants.COOKIE); + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; + if (!isLoggedIn) { + currentUser.postValue(Resource.success(null)); + return; + } + userRepository = UserRepository.Companion.getInstance(); + accountRepository = AccountRepository.Companion.getInstance(application); + fetchProfileDetails(); + } + + @Nullable + public Resource getCurrentUser() { + return currentUser.getValue(); + } + + public LiveData> getCurrentUserLiveData() { + return currentUser; + } + + public void fetchProfileDetails() { + currentUser.postValue(Resource.loading(null)); + final long uid = CookieUtils.getUserIdFromCookie(cookie); + if (userRepository == null) { + currentUser.postValue(Resource.success(null)); + return; + } + userRepository.getUserInfo(uid, CoroutineUtilsKt.getContinuation((user, throwable) -> { + if (throwable != null) { + Log.e(TAG, "onFailure: ", throwable); + final Resource userResource = currentUser.getValue(); + final User backup = userResource != null && userResource.data != null ? userResource.data : new User(uid); + currentUser.postValue(Resource.error(throwable.getMessage(), backup)); + return; + } + currentUser.postValue(Resource.success(user)); + if (accountRepository != null && user != null) { + accountRepository.insertOrUpdateAccount( + user.getPk(), + user.getUsername(), + cookie, + user.getFullName() != null ? user.getFullName() : "", + user.getProfilePicUrl(), + CoroutineUtilsKt.getContinuation((account, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "updateAccountInfo: ", throwable1); + } + }), Dispatchers.getIO()) + ); + } + }, Dispatchers.getIO())); + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/ArchivesViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/ArchivesViewModel.java new file mode 100644 index 0000000..be70eac --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/ArchivesViewModel.java @@ -0,0 +1,19 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.List; + +import awais.instagrabber.repositories.responses.stories.Story; + +public class ArchivesViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/CommentsViewerViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/CommentsViewerViewModel.java new file mode 100644 index 0000000..16a7b0d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/CommentsViewerViewModel.java @@ -0,0 +1,465 @@ +package awais.instagrabber.viewmodels; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.google.common.collect.ImmutableList; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.OptionalInt; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import awais.instagrabber.R; +import awais.instagrabber.models.Comment; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.ChildCommentsFetchResponse; +import awais.instagrabber.repositories.responses.CommentsFetchResponse; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.webservices.CommentService; +import awais.instagrabber.webservices.GraphQLRepository; +import awais.instagrabber.webservices.ServiceCallback; +import kotlin.coroutines.Continuation; +import kotlinx.coroutines.Dispatchers; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class CommentsViewerViewModel extends ViewModel { + private static final String TAG = CommentsViewerViewModel.class.getSimpleName(); + + private final MutableLiveData isLoggedIn = new MutableLiveData<>(false); + private final MutableLiveData currentUserId = new MutableLiveData<>(0L); + private final MutableLiveData>> rootList = new MutableLiveData<>(); + private final MutableLiveData rootCount = new MutableLiveData<>(0); + private final MutableLiveData>> replyList = new MutableLiveData<>(); + private final GraphQLRepository graphQLRepository; + + private String shortCode; + private String postId; + private String rootCursor; + private boolean rootHasNext = true; + private Comment repliesParent, replyTo; + private String repliesCursor; + private boolean repliesHasNext = true; + private final CommentService commentService; + private List prevReplies; + private String prevRepliesCursor; + private boolean prevRepliesHasNext = true; + + private final ServiceCallback ccb = new ServiceCallback() { + @Override + public void onSuccess(final CommentsFetchResponse result) { + // Log.d(TAG, "onSuccess: " + result); + if (result == null) { + rootList.postValue(Resource.error(R.string.generic_null_response, getPrevList(rootList))); + return; + } + List comments = result.getComments(); + if (rootCursor == null) { + rootCount.postValue(result.getCommentCount()); + } + if (rootCursor != null) { + comments = mergeList(rootList, comments); + } + rootCursor = result.getNextMinId(); + rootHasNext = result.getHasMoreComments(); + rootList.postValue(Resource.success(comments)); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + rootList.postValue(Resource.error(t.getMessage(), getPrevList(rootList))); + } + }; + private final ServiceCallback rcb = new ServiceCallback() { + @Override + public void onSuccess(final ChildCommentsFetchResponse result) { + // Log.d(TAG, "onSuccess: " + result); + if (result == null) { + rootList.postValue(Resource.error(R.string.generic_null_response, getPrevList(replyList))); + return; + } + List comments = result.getChildComments(); + // Replies + if (repliesCursor == null) { + // add parent to top of replies + comments = ImmutableList.builder() + .add(repliesParent) + .addAll(comments) + .build(); + } + if (repliesCursor != null) { + comments = mergeList(replyList, comments); + } + repliesCursor = result.getNextMaxChildCursor(); + repliesHasNext = result.getHasMoreTailChildComments(); + replyList.postValue(Resource.success(comments)); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + replyList.postValue(Resource.error(t.getMessage(), getPrevList(replyList))); + } + }; + + public CommentsViewerViewModel() { + graphQLRepository = GraphQLRepository.Companion.getInstance(); + final String cookie = settingsHelper.getString(Constants.COOKIE); + final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + final long userIdFromCookie = CookieUtils.getUserIdFromCookie(cookie); + commentService = CommentService.getInstance(deviceUuid, csrfToken, userIdFromCookie); + } + + public void setCurrentUser(final User currentUser) { + isLoggedIn.postValue(currentUser != null); + currentUserId.postValue(currentUser == null ? 0 : currentUser.getPk()); + } + + public void setPostDetails(final String shortCode, final String postId, final long postUserId) { + this.shortCode = shortCode; + this.postId = postId; + fetchComments(); + } + + public LiveData isLoggedIn() { + return isLoggedIn; + } + + public LiveData getCurrentUserId() { + return currentUserId; + } + + @Nullable + public Comment getRepliesParent() { + return repliesParent; + } + + @Nullable + public void setReplyTo(final Comment replyTo) { + this.replyTo = replyTo; + } + + public LiveData>> getRootList() { + return rootList; + } + + public LiveData>> getReplyList() { + return replyList; + } + + public LiveData getRootCommentsCount() { + return rootCount; + } + + public void fetchComments() { + if (shortCode == null || postId == null) return; + if (!rootHasNext) return; + rootList.postValue(Resource.loading(getPrevList(rootList))); + if (isLoggedIn.getValue()) { + commentService.fetchComments(postId, rootCursor, ccb); + return; + } + graphQLRepository.fetchComments( + shortCode, + true, + rootCursor, + enqueueRequest(true, shortCode, ccb) + ); + } + + public void fetchReplies() { + if (repliesParent == null) return; + fetchReplies(repliesParent.getPk()); + } + + public void fetchReplies(@NonNull final String commentId) { + if (!repliesHasNext) return; + final List list; + if (repliesParent != null && !Objects.equals(repliesParent.getPk(), commentId)) { + repliesCursor = null; + repliesHasNext = false; + list = Collections.emptyList(); + } else { + list = getPrevList(replyList); + } + replyList.postValue(Resource.loading(list)); + final Boolean isLoggedInValue = isLoggedIn.getValue(); + if (isLoggedInValue != null && isLoggedInValue) { + commentService.fetchChildComments(postId, commentId, repliesCursor, rcb); + return; + } + graphQLRepository.fetchComments(commentId, false, repliesCursor, enqueueRequest(false, commentId, rcb)); + } + + private Continuation enqueueRequest(final boolean root, + final String shortCodeOrCommentId, + @SuppressWarnings("rawtypes") final ServiceCallback callback) { + return CoroutineUtilsKt.getContinuation((response, throwable) -> { + if (throwable != null) { + callback.onFailure(throwable); + return; + } + if (response == null) { + Log.e(TAG, "Error occurred while fetching gql comments of " + shortCodeOrCommentId); + //noinspection unchecked + callback.onSuccess(null); + return; + } + try { + final JSONObject body = root ? new JSONObject(response).getJSONObject("data") + .getJSONObject("shortcode_media") + .getJSONObject("edge_media_to_parent_comment") + : new JSONObject(response).getJSONObject("data") + .getJSONObject("comment") + .getJSONObject("edge_threaded_comments"); + final int count = body.optInt("count"); + final JSONObject pageInfo = body.getJSONObject("page_info"); + final boolean hasNextPage = pageInfo.getBoolean("has_next_page"); + final String endCursor = pageInfo.isNull("end_cursor") || !hasNextPage ? null : pageInfo.optString("end_cursor"); + final JSONArray commentsJsonArray = body.getJSONArray("edges"); + final ImmutableList.Builder builder = ImmutableList.builder(); + for (int i = 0; i < commentsJsonArray.length(); i++) { + final Comment commentModel = getComment(commentsJsonArray.getJSONObject(i).getJSONObject("node"), root); + builder.add(commentModel); + } + final Object result = root ? new CommentsFetchResponse(count, endCursor, builder.build(), hasNextPage) + : new ChildCommentsFetchResponse(count, endCursor, builder.build(), hasNextPage); + //noinspection unchecked + callback.onSuccess(result); + } catch (Exception e) { + Log.e(TAG, "onResponse", e); + callback.onFailure(e); + } + }, Dispatchers.getIO()); + } + + @NonNull + private Comment getComment(@NonNull final JSONObject commentJsonObject, final boolean root) throws JSONException { + final JSONObject owner = commentJsonObject.getJSONObject("owner"); + final User user = new User( + owner.optLong(Constants.EXTRAS_ID, 0), + owner.getString(Constants.EXTRAS_USERNAME), + null, + false, + owner.getString("profile_pic_url"), + owner.optBoolean("is_verified")); + final JSONObject likedBy = commentJsonObject.optJSONObject("edge_liked_by"); + final String commentId = commentJsonObject.getString("id"); + final JSONObject childCommentsJsonObject = commentJsonObject.optJSONObject("edge_threaded_comments"); + int replyCount = 0; + if (childCommentsJsonObject != null) { + replyCount = childCommentsJsonObject.optInt("count"); + } + return new Comment(commentId, + commentJsonObject.getString("text"), + commentJsonObject.getLong("created_at"), + likedBy != null ? likedBy.optLong("count", 0) : 0, + commentJsonObject.getBoolean("viewer_has_liked"), + user, + replyCount); + } + + @NonNull + private List getPrevList(@NonNull final LiveData>> list) { + if (list.getValue() == null) return Collections.emptyList(); + final Resource> listResource = list.getValue(); + if (listResource.data == null) return Collections.emptyList(); + return listResource.data; + } + + private List mergeList(@NonNull final LiveData>> list, + final List comments) { + final List prevList = getPrevList(list); + if (comments == null) { + return prevList; + } + return ImmutableList.builder() + .addAll(prevList) + .addAll(comments) + .build(); + } + + public void showReplies(final Comment comment) { + if (comment == null) return; + if (repliesParent == null || !Objects.equals(repliesParent.getPk(), comment.getPk())) { + repliesParent = comment; + replyTo = comment; + prevReplies = null; + prevRepliesCursor = null; + prevRepliesHasNext = true; + fetchReplies(comment.getPk()); + return; + } + if (prevReplies != null && !prevReplies.isEmpty()) { + // user clicked same comment, show prev loaded replies + repliesCursor = prevRepliesCursor; + repliesHasNext = prevRepliesHasNext; + replyList.postValue(Resource.success(prevReplies)); + return; + } + // prev list was null or empty, fetch + prevRepliesCursor = null; + prevRepliesHasNext = true; + fetchReplies(comment.getPk()); + } + + public LiveData> likeComment(@NonNull final Comment comment, final boolean liked, final boolean isReply) { + final MutableLiveData> data = new MutableLiveData<>(Resource.loading(null)); + final ServiceCallback callback = new ServiceCallback() { + @Override + public void onSuccess(final Boolean result) { + if (result == null || !result) { + data.postValue(Resource.error(R.string.downloader_unknown_error, null)); + return; + } + data.postValue(Resource.success(new Object())); + setLiked(isReply, comment, liked); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error liking comment", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }; + if (liked) { + commentService.commentLike(comment.getPk(), callback); + } else { + commentService.commentUnlike(comment.getPk(), callback); + } + return data; + } + + private void setLiked(final boolean isReply, + @NonNull final Comment comment, + final boolean liked) { + final List list = getPrevList(isReply ? replyList : rootList); + if (list == null) return; + final List copy = new ArrayList<>(list); + OptionalInt indexOpt = IntStream.range(0, copy.size()) + .filter(i -> copy.get(i) != null && Objects.equals(copy.get(i).getPk(), comment.getPk())) + .findFirst(); + if (!indexOpt.isPresent()) return; + try { + final Comment clone = (Comment) comment.clone(); + clone.setLiked(liked); + copy.set(indexOpt.getAsInt(), clone); + final MutableLiveData>> liveData = isReply ? replyList : rootList; + liveData.postValue(Resource.success(copy)); + } catch (Exception e) { + Log.e(TAG, "setLiked: ", e); + } + } + + public LiveData> comment(@NonNull final String text, + final boolean isReply) { + final MutableLiveData> data = new MutableLiveData<>(Resource.loading(null)); + String replyToId = null; + if (isReply && replyTo != null) { + replyToId = replyTo.getPk(); + } + if (isReply && replyToId == null) { + data.postValue(Resource.error(null, null)); + return data; + } + commentService.comment(postId, text, replyToId, new ServiceCallback() { + @Override + public void onSuccess(final Comment result) { + if (result == null) { + data.postValue(Resource.error(R.string.downloader_unknown_error, null)); + return; + } + addComment(result, isReply); + data.postValue(Resource.success(new Object())); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error during comment", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private void addComment(@NonNull final Comment comment, final boolean isReply) { + final List list = getPrevList(isReply ? replyList : rootList); + final ImmutableList.Builder builder = ImmutableList.builder(); + if (isReply) { + // replies are added to the bottom of the list to preserve chronological order + builder.addAll(list) + .add(comment); + } else { + builder.add(comment) + .addAll(list); + } + final MutableLiveData>> liveData = isReply ? replyList : rootList; + liveData.postValue(Resource.success(builder.build())); + } + + public void translate(@NonNull final Comment comment, + @NonNull final ServiceCallback callback) { + commentService.translate(comment.getPk(), callback); + } + + public LiveData> deleteComment(@NonNull final Comment comment, final boolean isReply) { + final MutableLiveData> data = new MutableLiveData<>(Resource.loading(null)); + commentService.deleteComment(postId, comment.getPk(), new ServiceCallback() { + @Override + public void onSuccess(final Boolean result) { + if (result == null || !result) { + data.postValue(Resource.error(R.string.downloader_unknown_error, null)); + return; + } + removeComment(comment, isReply); + data.postValue(Resource.success(new Object())); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "Error deleting comment", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private void removeComment(@NonNull final Comment comment, final boolean isReply) { + final List list = getPrevList(isReply ? replyList : rootList); + final List updated = list.stream() + .filter(Objects::nonNull) + .filter(c -> !Objects.equals(c.getPk(), comment.getPk())) + .collect(Collectors.toList()); + final MutableLiveData>> liveData = isReply ? replyList : rootList; + liveData.postValue(Resource.success(updated)); + } + + public void clearReplies() { + prevRepliesCursor = repliesCursor; + prevRepliesHasNext = repliesHasNext; + repliesCursor = null; + repliesHasNext = true; + // cache prev reply list to save time and data if user clicks same comment again + prevReplies = getPrevList(replyList); + replyList.postValue(Resource.success(Collections.emptyList())); + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.kt new file mode 100644 index 0000000..5c748b5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.kt @@ -0,0 +1,37 @@ +package awais.instagrabber.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.managers.InboxManager +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.DirectInbox +import awais.instagrabber.repositories.responses.directmessages.DirectThread + +class DirectInboxViewModel : ViewModel() { + private val inboxManager: InboxManager = DirectMessagesManager.inboxManager + val inbox: LiveData> = inboxManager.getInbox() + val threads: LiveData> = inboxManager.threads + val unseenCount: LiveData> = inboxManager.getUnseenCount() + val pendingRequestsTotal: LiveData = inboxManager.getPendingRequestsTotal() + val viewer: User? = inboxManager.viewer + + fun fetchInbox() { + inboxManager.fetchInbox(viewModelScope) + } + + fun refresh() { + inboxManager.refresh(viewModelScope) + } + + fun onDestroy() { + inboxManager.onDestroy() + } + + init { + inboxManager.fetchInbox(viewModelScope) + inboxManager.fetchUnseenCount(viewModelScope) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectPendingInboxViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/DirectPendingInboxViewModel.kt new file mode 100644 index 0000000..b269ac9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectPendingInboxViewModel.kt @@ -0,0 +1,34 @@ +package awais.instagrabber.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import awais.instagrabber.managers.DirectMessagesManager.pendingInboxManager +import awais.instagrabber.managers.InboxManager +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.DirectInbox +import awais.instagrabber.repositories.responses.directmessages.DirectThread + +class DirectPendingInboxViewModel : ViewModel() { + private val inboxManager: InboxManager = pendingInboxManager + val threads: LiveData> = inboxManager.threads + val inbox: LiveData> = inboxManager.getInbox() + val viewer: User? = inboxManager.viewer + + fun fetchInbox() { + inboxManager.fetchInbox(viewModelScope) + } + + fun refresh() { + inboxManager.refresh(viewModelScope) + } + + fun onDestroy() { + inboxManager.onDestroy() + } + + init { + inboxManager.fetchInbox(viewModelScope) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.kt new file mode 100644 index 0000000..eea4514 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.kt @@ -0,0 +1,201 @@ +package awais.instagrabber.viewmodels + +import android.app.Application +import androidx.annotation.StringRes +import androidx.core.util.Pair +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import awais.instagrabber.R +import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.DirectThread +import awais.instagrabber.repositories.responses.directmessages.DirectThreadParticipantRequestsResponse +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.getCsrfTokenFromCookie +import awais.instagrabber.utils.getUserIdFromCookie + +class DirectSettingsViewModel( + application: Application, + threadId: String, + pending: Boolean, + currentUser: User, +) : AndroidViewModel(application) { + private val viewerId: Long + private val resources = application.resources + private val threadManager = DirectMessagesManager.getThreadManager(threadId, pending, currentUser, application.contentResolver) + + val thread: LiveData = threadManager.thread + + // public void setThread(@NonNull final DirectThread thread) { + // this.thread = thread; + // inputMode.postValue(thread.getInputMode()); + // List users = thread.getUsers(); + // final ImmutableList.Builder builder = ImmutableList.builder().add(currentUser); + // if (users != null) { + // builder.addAll(users); + // } + // users = builder.build(); + // this.users.postValue(new Pair<>(users, thread.getLeftUsers())); + // // setTitle(thread.getThreadTitle()); + // final List adminUserIds = thread.getAdminUserIds(); + // this.adminUserIds.postValue(adminUserIds); + // viewerIsAdmin = adminUserIds.contains(viewerId); + // muted.postValue(thread.getMuted()); + // mentionsMuted.postValue(thread.isMentionsMuted()); + // approvalRequiredToJoin.postValue(thread.isApprovalRequiredForNewMembers()); + // isPending.postValue(thread.isPending()); + // if (thread.getInputMode() != 1 && thread.isGroup() && viewerIsAdmin) { + // fetchPendingRequests(); + // } + // } + val inputMode: LiveData = threadManager.inputMode + + fun isGroup(): LiveData = threadManager.isGroup + + fun getUsers(): LiveData> = threadManager.usersWithCurrent + + fun getLeftUsers(): LiveData> = threadManager.leftUsers + + fun getUsersAndLeftUsers(): LiveData, List>> = threadManager.usersAndLeftUsers + + fun getTitle(): LiveData = threadManager.threadTitle + + // public void setTitle(final String title) { + // if (title == null) { + // this.title.postValue(""); + // return; + // } + // this.title.postValue(title.trim()); + // } + fun getAdminUserIds(): LiveData> = threadManager.adminUserIds + + fun isMuted(): LiveData = threadManager.isMuted + + fun getApprovalRequiredToJoin(): LiveData = threadManager.isApprovalRequiredToJoin + + fun getPendingRequests(): LiveData = threadManager.pendingRequests + + fun isPending(): LiveData = threadManager.isPending + + fun isViewerAdmin(): LiveData = threadManager.isViewerAdmin + + fun updateTitle(newTitle: String): LiveData> = threadManager.updateTitle(newTitle, viewModelScope) + + fun addMembers(users: Set): LiveData> = threadManager.addMembers(users, viewModelScope) + + fun removeMember(user: User): LiveData> = threadManager.removeMember(user, viewModelScope) + + private fun makeAdmin(user: User): LiveData> = threadManager.makeAdmin(user, viewModelScope) + + private fun removeAdmin(user: User): LiveData> = threadManager.removeAdmin(user, viewModelScope) + + fun mute(): LiveData> = threadManager.mute(viewModelScope) + + fun unmute(): LiveData> = threadManager.unmute(viewModelScope) + + fun muteMentions(): LiveData> = threadManager.muteMentions(viewModelScope) + + fun unmuteMentions(): LiveData> = threadManager.unmuteMentions(viewModelScope) + + private fun blockUser(user: User): LiveData> = threadManager.blockUser(user, viewModelScope) + + private fun unblockUser(user: User): LiveData> = threadManager.unblockUser(user, viewModelScope) + + private fun restrictUser(user: User): LiveData> = threadManager.restrictUser(user, viewModelScope) + + private fun unRestrictUser(user: User): LiveData> = threadManager.unRestrictUser(user, viewModelScope) + + fun approveUsers(users: List): LiveData> = threadManager.approveUsers(users, viewModelScope) + + fun denyUsers(users: List): LiveData> = threadManager.denyUsers(users, viewModelScope) + + fun approvalRequired(): LiveData> = threadManager.approvalRequired(viewModelScope) + + fun approvalNotRequired(): LiveData> = threadManager.approvalNotRequired(viewModelScope) + + fun leave(): LiveData> = threadManager.leave(viewModelScope) + + fun end(): LiveData> = threadManager.end(viewModelScope) + + fun createUserOptions(user: User?): ArrayList> { + val options: ArrayList> = ArrayList() + if (user == null || isSelf(user) || hasLeft(user)) { + return options + } + val viewerIsAdmin: Boolean? = threadManager.isViewerAdmin.value + if (viewerIsAdmin != null && viewerIsAdmin) { + options.add(Option(getString(R.string.dms_action_kick), ACTION_KICK)) + val isAdmin: Boolean = threadManager.isAdmin(user) + options.add(Option( + if (isAdmin) getString(R.string.dms_action_remove_admin) else getString(R.string.dms_action_make_admin), + if (isAdmin) ACTION_REMOVE_ADMIN else ACTION_MAKE_ADMIN + )) + } + val blocking: Boolean = user.friendshipStatus?.blocking ?: false + options.add(Option( + if (blocking) getString(R.string.unblock) else getString(R.string.block), + if (blocking) ACTION_UNBLOCK else ACTION_BLOCK + )) + + // options.add(new Option<>(getString(R.string.report), ACTION_REPORT)); + val isGroup: Boolean? = threadManager.isGroup.value + if (isGroup != null && isGroup) { + val restricted: Boolean = user.friendshipStatus?.isRestricted ?: false + options.add(Option( + if (restricted) getString(R.string.unrestrict) else getString(R.string.restrict), + if (restricted) ACTION_UNRESTRICT else ACTION_RESTRICT + )) + } + return options + } + + private fun hasLeft(user: User): Boolean { + val leftUsers: List = getLeftUsers().value ?: return false + return leftUsers.contains(user) + } + + private fun isSelf(user: User): Boolean = user.pk == viewerId + + private fun getString(@StringRes resId: Int): String { + return resources.getString(resId) + } + + fun doAction(user: User?, action: String?): LiveData>? { + return if (user == null || action == null) null else when (action) { + ACTION_KICK -> removeMember(user) + ACTION_MAKE_ADMIN -> makeAdmin(user) + ACTION_REMOVE_ADMIN -> removeAdmin(user) + ACTION_BLOCK -> blockUser(user) + ACTION_UNBLOCK -> unblockUser(user) + ACTION_RESTRICT -> restrictUser(user) + ACTION_UNRESTRICT -> unRestrictUser(user) + else -> null + } + } + + fun getInviter(): LiveData = threadManager.inviter + + companion object { + private const val ACTION_KICK = "kick" + private const val ACTION_MAKE_ADMIN = "make_admin" + private const val ACTION_REMOVE_ADMIN = "remove_admin" + private const val ACTION_BLOCK = "block" + private const val ACTION_UNBLOCK = "unblock" + + // private static final String ACTION_REPORT = "report"; + private const val ACTION_RESTRICT = "restrict" + private const val ACTION_UNRESTRICT = "unrestrict" + } + + init { + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + viewerId = getUserIdFromCookie(cookie) + val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + val csrfToken = getCsrfTokenFromCookie(cookie) + require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.kt new file mode 100644 index 0000000..479ce01 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.kt @@ -0,0 +1,213 @@ +package awais.instagrabber.viewmodels + +import android.app.Application +import android.content.ContentResolver +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.* +import awais.instagrabber.customviews.emoji.Emoji +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.managers.DirectMessagesManager.inboxManager +import awais.instagrabber.managers.ThreadManager +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Resource.Companion.error +import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.DirectItem +import awais.instagrabber.repositories.responses.directmessages.DirectThread +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.repositories.responses.giphy.GiphyGif +import awais.instagrabber.utils.* +import awais.instagrabber.utils.MediaUtils.OnInfoLoadListener +import awais.instagrabber.utils.MediaUtils.VideoInfo +import awais.instagrabber.utils.VoiceRecorder.VoiceRecorderCallback +import awais.instagrabber.utils.VoiceRecorder.VoiceRecordingResult + + +class DirectThreadViewModel( + application: Application, + val threadId: String, + pending: Boolean, + val currentUser: User, +) : AndroidViewModel(application) { + // private val TAG = DirectThreadViewModel::class.java.simpleName + + // private static final String ERROR_INVALID_THREAD = "Invalid thread"; + private val contentResolver: ContentResolver = application.contentResolver + private val recordingsDir: DocumentFile? = DownloadUtils.recordingsDir + private var voiceRecorder: VoiceRecorder? = null + private lateinit var threadManager: ThreadManager + + val viewerId: Long + val threadTitle: LiveData by lazy { threadManager.threadTitle } + val thread: LiveData by lazy { threadManager.thread } + val items: LiveData> by lazy { + Transformations.map(threadManager.items) { it.filter { thread -> thread.hideInThread == 0 } } + } + val isFetching: LiveData> by lazy { threadManager.fetching } + val users: LiveData> by lazy { threadManager.users } + val leftUsers: LiveData> by lazy { threadManager.leftUsers } + val pendingRequestsCount: LiveData by lazy { threadManager.pendingRequestsCount } + val inputMode: LiveData by lazy { threadManager.inputMode } + val isPending: LiveData by lazy { threadManager.isPending } + val replyToItem: LiveData by lazy { threadManager.replyToItem } + + fun moveFromPending() { + val messagesManager = DirectMessagesManager + messagesManager.moveThreadFromPending(threadId) + threadManager = messagesManager.getThreadManager(threadId, false, currentUser, contentResolver) + } + + fun removeThread() { + threadManager.removeThread() + } + + fun fetchChats() { + threadManager.fetchChats(viewModelScope) + } + + fun refreshChats() { + threadManager.refreshChats(viewModelScope) + } + + fun sendText(text: String): LiveData> { + return threadManager.sendText(text, viewModelScope) + } + + fun sendUri(uri: Uri): LiveData> { + return threadManager.sendUri(uri, viewModelScope) + } + + fun startRecording(): LiveData> { + val data = MutableLiveData>() + voiceRecorder = VoiceRecorder(recordingsDir!!, object : VoiceRecorderCallback { + override fun onStart() {} + override fun onComplete(result: VoiceRecordingResult) { + // Log.d(TAG, "onComplete: recording complete. Scanning file..."); + MediaUtils.getVoiceInfo( + contentResolver, + result.file.uri, + object : OnInfoLoadListener { + override fun onLoad(videoInfo: VideoInfo?) { + if (videoInfo == null) return + threadManager.sendVoice( + data, + result.file.uri, + result.waveform, + result.samplingFreq, + videoInfo.duration, + result.file.length(), + viewModelScope + ) + } + + override fun onFailure(t: Throwable) { + data.postValue(error(t.message, null)) + } + }) + } + + override fun onCancel() {} + }) + voiceRecorder?.startRecording(contentResolver) + return data + } + + fun stopRecording(delete: Boolean) { + voiceRecorder?.stopRecording(delete) + voiceRecorder = null + } + + fun sendReaction(item: DirectItem, emoji: Emoji): LiveData> { + return threadManager.sendReaction(item, emoji, viewModelScope) + } + + fun sendDeleteReaction(itemId: String): LiveData> { + return threadManager.sendDeleteReaction(itemId, viewModelScope) + } + + fun unsend(item: DirectItem): LiveData> { + return threadManager.unsend(item, viewModelScope) + } + + fun sendAnimatedMedia(giphyGif: GiphyGif): LiveData> { + return threadManager.sendAnimatedMedia(giphyGif, viewModelScope) + } + + fun getUser(userId: Long): User? { + var match: User? = null + users.value?.let { match = it.firstOrNull { user -> user.pk == userId } } + if (match == null) { + leftUsers.value?.let { match = it.firstOrNull { user -> user.pk == userId } } + } + return match + } + + fun forward(recipients: Set, itemToForward: DirectItem) { + threadManager.forward(recipients, itemToForward, viewModelScope) + } + + fun forward(recipient: RankedRecipient, itemToForward: DirectItem) { + threadManager.forward(recipient, itemToForward, viewModelScope) + } + + fun setReplyToItem(item: DirectItem?) { + // Log.d(TAG, "setReplyToItem: " + item); + threadManager.setReplyToItem(item) + } + + fun acceptRequest(): LiveData> { + return threadManager.acceptRequest(viewModelScope) + } + + fun declineRequest(): LiveData> { + return threadManager.declineRequest(viewModelScope) + } + + fun markAsSeen(): LiveData> { + val thread = thread.value ?: return successEventResObjectLiveData + val items = thread.items + if (items.isNullOrEmpty()) return successEventResObjectLiveData + val directItem = items.firstOrNull { (_, userId) -> userId != currentUser.pk } ?: return successEventResObjectLiveData + val lastSeenAt = thread.lastSeenAt + if (lastSeenAt != null) { + val seenAt = lastSeenAt[currentUser.pk] ?: return successEventResObjectLiveData + try { + val timestamp = seenAt.timestamp ?: return successEventResObjectLiveData + val itemIdMatches = seenAt.itemId == directItem.itemId + val timestampMatches = timestamp.toLong() >= directItem.getTimestamp() + if (itemIdMatches || timestampMatches) { + return successEventResObjectLiveData + } + } catch (ignored: Exception) { + return successEventResObjectLiveData + } + } + return threadManager.markAsSeen(directItem, viewModelScope) + } + + private val successEventResObjectLiveData: MutableLiveData> + get() { + val data = MutableLiveData>() + data.postValue(success(Any())) + return data + } + + fun deleteThreadIfRequired() { + val thread = thread.value ?: return + if (thread.isTemp && thread.items.isNullOrEmpty()) { + val inboxManager = inboxManager + inboxManager.removeThread(threadId) + } + } + + init { + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + viewerId = getUserIdFromCookie(cookie) + val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + val csrfToken = getCsrfTokenFromCookie(cookie) + require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } + threadManager = DirectMessagesManager.getThreadManager(threadId, pending, currentUser, contentResolver) + threadManager.fetchPendingRequests(viewModelScope) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectorySelectActivityViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/DirectorySelectActivityViewModel.java new file mode 100644 index 0000000..03e0420 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectorySelectActivityViewModel.java @@ -0,0 +1,113 @@ +package awais.instagrabber.viewmodels; + +import android.app.Application; +import android.content.Intent; +import android.content.UriPermission; +import android.net.Uri; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; + +import static awais.instagrabber.fragments.settings.PreferenceKeys.FOLDER_PATH; + +public class DirectorySelectActivityViewModel extends AndroidViewModel { + private static final String TAG = DirectorySelectActivityViewModel.class.getSimpleName(); + + private final MutableLiveData message = new MutableLiveData<>(); + private final MutableLiveData prevUri = new MutableLiveData<>(); + private final MutableLiveData loading = new MutableLiveData<>(false); + private final MutableLiveData dirSuccess = new MutableLiveData<>(false); + + public DirectorySelectActivityViewModel(final Application application) { + super(application); + } + + public LiveData getMessage() { + return message; + } + + public LiveData getPrevUri() { + return prevUri; + } + + public LiveData isLoading() { + return loading; + } + + public LiveData getDirSuccess() { + return dirSuccess; + } + + public void setInitialUri(final Intent intent) { + if (intent == null) { + setMessage(null); + return; + } + final Parcelable initialUriParcelable = intent.getParcelableExtra(Constants.EXTRA_INITIAL_URI); + if (!(initialUriParcelable instanceof Uri)) { + setMessage(null); + return; + } + setMessage((Uri) initialUriParcelable); + } + + private void setMessage(@Nullable final Uri initialUri) { + if (initialUri == null) { + final String prevVersionFolderPath = Utils.settingsHelper.getString(FOLDER_PATH); + if (TextUtils.isEmpty(prevVersionFolderPath)) { + // default message + message.postValue(getApplication().getString(R.string.dir_select_default_message)); + prevUri.postValue(null); + return; + } + message.postValue(getApplication().getString(R.string.dir_select_reselect_message)); + prevUri.postValue(prevVersionFolderPath); + return; + } + final List existingPermissions = getApplication().getContentResolver().getPersistedUriPermissions(); + final boolean anyMatch = existingPermissions.stream().anyMatch(uriPermission -> uriPermission.getUri().equals(initialUri)); + final DocumentFile documentFile = DocumentFile.fromSingleUri(getApplication(), initialUri); + String path; + try { + path = URLDecoder.decode(initialUri.toString(), StandardCharsets.UTF_8.toString()); + } catch (UnsupportedEncodingException e) { + path = initialUri.toString(); + } + if (!anyMatch) { + message.postValue(getApplication().getString(R.string.dir_select_permission_revoked_message)); + prevUri.postValue(path); + return; + } + if (documentFile == null || !documentFile.exists() || documentFile.lastModified() == 0) { + message.postValue(getApplication().getString(R.string.dir_select_folder_not_exist)); + prevUri.postValue(path); + } + } + + public void setupSelectedDir(@NonNull final Intent data) throws DownloadUtils.ReselectDocumentTreeException { + loading.postValue(true); + try { + Utils.setupSelectedDir(getApplication(), data); + message.postValue(getApplication().getString(R.string.dir_select_success_message)); + dirSuccess.postValue(true); + } finally { + loading.postValue(false); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/FavoritesViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/FavoritesViewModel.kt new file mode 100644 index 0000000..62a75ec --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/FavoritesViewModel.kt @@ -0,0 +1,47 @@ +package awais.instagrabber.viewmodels + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.db.repositories.FavoriteRepository +import awais.instagrabber.utils.extensions.TAG +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class FavoritesViewModel(application: Application) : AndroidViewModel(application) { + private val _list = MutableLiveData>() + val list: LiveData> = _list + + private val favoriteRepository: FavoriteRepository = FavoriteRepository.getInstance(application) + + init { + fetch() + } + + fun fetch() { + viewModelScope.launch(Dispatchers.IO) { + try { + _list.postValue(favoriteRepository.getAllFavorites()) + } catch (e: Exception) { + Log.e(TAG, "fetch: ", e) + } + } + } + + fun delete(favorite: Favorite, onSuccess: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + favoriteRepository.deleteFavorite(favorite.query, favorite.type) + withContext(Dispatchers.Main) { onSuccess() } + _list.postValue(favoriteRepository.getAllFavorites()) + } catch (e: Exception) { + Log.e(TAG, "delete: ", e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/FeedStoriesViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/FeedStoriesViewModel.java new file mode 100644 index 0000000..61118fe --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/FeedStoriesViewModel.java @@ -0,0 +1,19 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.List; + +import awais.instagrabber.repositories.responses.stories.Story; + +public class FeedStoriesViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/FileListViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/FileListViewModel.java new file mode 100644 index 0000000..c8ebcd5 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/FileListViewModel.java @@ -0,0 +1,18 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.io.File; +import java.util.List; + +public class FileListViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/FiltersFragmentViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/FiltersFragmentViewModel.java new file mode 100644 index 0000000..fe80800 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/FiltersFragmentViewModel.java @@ -0,0 +1,26 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class FiltersFragmentViewModel extends ViewModel { + private final MutableLiveData loading = new MutableLiveData<>(false); + private final MutableLiveData currentTab = new MutableLiveData<>(); + + public FiltersFragmentViewModel() { + } + + public LiveData isLoading() { + return loading; + } + + public LiveData getCurrentTab() { + return currentTab; + } + + public void setCurrentTab(final ImageEditViewModel.Tab tab) { + if (tab == null) return; + currentTab.postValue(tab); + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/FollowViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/FollowViewModel.kt new file mode 100644 index 0000000..a0a89b7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/FollowViewModel.kt @@ -0,0 +1,163 @@ +package awais.instagrabber.viewmodels + +import androidx.lifecycle.* +import awais.instagrabber.models.Resource +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.webservices.FriendshipRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class FollowViewModel : ViewModel() { + // data + val userId = MutableLiveData() + private val followers = MutableLiveData>() + private val followings = MutableLiveData>() + private val searchResults = MutableLiveData>() + + // cursors + private val followersMaxId = MutableLiveData("") + private val followingMaxId = MutableLiveData("") + private val searchingMaxId = MutableLiveData("") + private val searchQuery = MutableLiveData() + + // comparison + val status: LiveData> = object : MediatorLiveData>() { + init { + postValue(Pair(false, false)) + addSource(followersMaxId) { + if (it == null) { + postValue(Pair(true, value!!.second)) + } + else fetch(true, it) + } + addSource(followingMaxId) { + if (it == null) { + postValue(Pair(value!!.first, true)) + } + else fetch(false, it) + } + } + } + val comparison: LiveData, List, List>> = + object : MediatorLiveData, List, List>>() { + init { + addSource(status) { + if (it.first && it.second) { + val followersList = followers.value!! + val followingList = followings.value!! + val allUsers: MutableList = mutableListOf() + allUsers.addAll(followersList) + allUsers.addAll(followingList) + val followersMap = followersList.groupBy { it.pk } + val followingMap = followingList.groupBy { it.pk } + val mutual: MutableList = mutableListOf() + val onlyFollowing: MutableList = mutableListOf() + val onlyFollowers: MutableList = mutableListOf() + allUsers.forEach { + val isFollowing = followingMap.get(it.pk) != null + val isFollower = followersMap.get(it.pk) != null + if (isFollowing && isFollower) mutual.add(it) + else if (isFollowing) onlyFollowing.add(it) + else if (isFollower) onlyFollowers.add(it) + } + postValue(Triple(mutual, onlyFollowing, onlyFollowers)) + } + } + } + } + + private val friendshipRepository: FriendshipRepository by lazy { FriendshipRepository.getInstance() } + + // fetch: supply max ID for continuous fetch + fun fetch(follower: Boolean, nextMaxId: String?): LiveData> { + val data = MutableLiveData>() + data.postValue(Resource.loading(null)) + val maxId = if (follower) followersMaxId else followingMaxId + if (maxId.value == null && nextMaxId == null) data.postValue(Resource.success(null)) + else if (userId.value == null) data.postValue(Resource.error("No user ID supplied!", null)) + else viewModelScope.launch(Dispatchers.IO) { + try { + val tempList = friendshipRepository.getList( + follower, + userId.value!!, + nextMaxId ?: maxId.value, + null + ) + if (!tempList.status.equals("ok")) { + data.postValue(Resource.error("Status not ok!", null)) + } + else { + if (tempList.users != null) { + val liveData = if (follower) followers else followings + val currentList = if (liveData.value != null) liveData.value!!.toMutableList() + else mutableListOf() + currentList.addAll(tempList.users!!) + liveData.postValue(currentList.toList()) + } + maxId.postValue(tempList.nextMaxId) + data.postValue(Resource.success(null)) + } + } catch (e: Exception) { + data.postValue(Resource.error(e.message, null)) + } + } + return data + } + + fun getList(follower: Boolean): LiveData> { + return if (follower) followers else followings + } + + fun search(follower: Boolean): LiveData> { + val data = MutableLiveData>() + data.postValue(Resource.loading(null)) + val query = searchQuery.value + if (searchingMaxId.value == null) data.postValue(Resource.success(null)) + else if (userId.value == null) data.postValue(Resource.error("No user ID supplied!", null)) + else if (query.isNullOrEmpty()) data.postValue(Resource.error("No query supplied!", null)) + else viewModelScope.launch(Dispatchers.IO) { + try { + val tempList = friendshipRepository.getList( + follower, + userId.value!!, + searchingMaxId.value, + query + ) + if (!tempList.status.equals("ok")) { + data.postValue(Resource.error("Status not ok!", null)) + } + else { + if (tempList.users != null) { + val currentList = if (searchResults.value != null) searchResults.value!!.toMutableList() + else mutableListOf() + currentList.addAll(tempList.users!!) + searchResults.postValue(currentList.toList()) + } + searchingMaxId.postValue(tempList.nextMaxId) + data.postValue(Resource.success(null)) + } + } catch (e: Exception) { + data.postValue(Resource.error(e.message, null)) + } + } + return data + } + + fun getSearch(): LiveData> { + return searchResults + } + + fun setQuery(query: String?, follower: Boolean) { + searchQuery.value = query + if (!query.isNullOrEmpty()) search(follower) + } + + fun clearProgress() { + followersMaxId.value = "" + followingMaxId.value = "" + searchingMaxId.value = "" + followings.value = listOf() + followers.value = listOf() + searchResults.value = listOf() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/GifPickerViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/GifPickerViewModel.java new file mode 100644 index 0000000..6945028 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/GifPickerViewModel.java @@ -0,0 +1,140 @@ +package awais.instagrabber.viewmodels; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.google.common.collect.ImmutableList; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.AnimatedMediaFixedHeight; +import awais.instagrabber.repositories.responses.giphy.GiphyGif; +import awais.instagrabber.repositories.responses.giphy.GiphyGifImages; +import awais.instagrabber.repositories.responses.giphy.GiphyGifResponse; +import awais.instagrabber.repositories.responses.giphy.GiphyGifResults; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.GifService; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class GifPickerViewModel extends ViewModel { + private static final String TAG = GifPickerViewModel.class.getSimpleName(); + + private final MutableLiveData>> images = new MutableLiveData<>(Resource.success(Collections.emptyList())); + private final GifService gifService; + + private Call searchRequest; + + public GifPickerViewModel() { + gifService = GifService.getInstance(); + search(null); + } + + public LiveData>> getImages() { + return images; + } + + public void search(final String query) { + final Resource> currentValue = images.getValue(); + if (currentValue != null && currentValue.status == Resource.Status.LOADING) { + cancelSearchRequest(); + } + images.postValue(Resource.loading(getCurrentImages())); + searchRequest = gifService.searchGiphyGifs(query, query != null); + searchRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, + @NonNull final Response response) { + if (response.isSuccessful()) { + parseResponse(response); + return; + } + if (response.errorBody() != null) { + try { + final String string = response.errorBody().string(); + final String msg = String.format(Locale.US, + "onResponse: url: %s, responseCode: %d, errorBody: %s", + call.request().url().toString(), + response.code(), + string); + images.postValue(Resource.error(msg, getCurrentImages())); + Log.e(TAG, msg); + } catch (IOException e) { + images.postValue(Resource.error(e.getMessage(), getCurrentImages())); + Log.e(TAG, "onResponse: ", e); + } + } + images.postValue(Resource.error(R.string.generic_failed_request, getCurrentImages())); + } + + @Override + public void onFailure(@NonNull final Call call, + @NonNull final Throwable t) { + images.postValue(Resource.error(t.getMessage(), getCurrentImages())); + Log.e(TAG, "enqueueRequest: onFailure: ", t); + } + }); + } + + private void parseResponse(final Response response) { + final GiphyGifResponse giphyGifResponse = response.body(); + if (giphyGifResponse == null) { + images.postValue(Resource.error(R.string.generic_null_response, getCurrentImages())); + return; + } + final GiphyGifResults results = giphyGifResponse.getResults(); + images.postValue(Resource.success( + ImmutableList.builder() + .addAll(results.getGiphy() == null ? Collections.emptyList() : filterInvalid(results.getGiphy())) + .addAll(results.getGiphyGifs() == null ? Collections.emptyList() : filterInvalid(results.getGiphyGifs())) + .build() + )); + } + + private List filterInvalid(@NonNull final List giphyGifs) { + return giphyGifs.stream() + .filter(Objects::nonNull) + .filter(giphyGif -> { + final GiphyGifImages images = giphyGif.getImages(); + if (images == null) return false; + final AnimatedMediaFixedHeight fixedHeight = images.getFixedHeight(); + if (fixedHeight == null) return false; + return !TextUtils.isEmpty(fixedHeight.getWebp()); + }) + .collect(Collectors.toList()); + } + + // @NonNull + // private List getGiphyGifImages(@NonNull final List giphy) { + // return giphy.stream() + // .map(giphyGif -> { + // final GiphyGifImages images = giphyGif.getImages(); + // if (images == null) return null; + // return images.getOriginal(); + // }) + // .filter(Objects::nonNull) + // .collect(Collectors.toList()); + // } + + private List getCurrentImages() { + final Resource> value = images.getValue(); + return value == null ? Collections.emptyList() : value.data; + } + + public void cancelSearchRequest() { + if (searchRequest == null) return; + searchRequest.cancel(); + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/ImageEditViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/ImageEditViewModel.java new file mode 100644 index 0000000..11c8f15 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/ImageEditViewModel.java @@ -0,0 +1,221 @@ +package awais.instagrabber.viewmodels; + +import android.app.Application; +import android.graphics.RectF; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import java.io.File; +import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import awais.instagrabber.fragments.imageedit.filters.FiltersHelper.FilterType; +import awais.instagrabber.fragments.imageedit.filters.filters.Filter; +import awais.instagrabber.fragments.imageedit.filters.properties.Property; +import awais.instagrabber.models.SavedImageEditState; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.DownloadUtils; +import awais.instagrabber.utils.SerializablePair; +import awais.instagrabber.utils.Utils; +import jp.co.cyberagent.android.gpuimage.GPUImage; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; +import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; + +public class ImageEditViewModel extends AndroidViewModel { + private static final String CROP = "crop"; + private static final String RESULT = "result"; + private static final String FILE_FORMAT = "yyyyMMddHHmmssSSS"; + private static final String MIME_TYPE = Utils.mimeTypeMap.getMimeTypeFromExtension("jpg"); + private static final DateTimeFormatter SIMPLE_DATE_FORMAT = DateTimeFormatter.ofPattern(FILE_FORMAT, Locale.US); + + private Uri originalUri; + private SavedImageEditState savedImageEditState; + + private final String sessionId; + private final Uri destinationUri; + private final Uri cropDestinationUri; + private final MutableLiveData loading = new MutableLiveData<>(false); + private final MutableLiveData resultUri = new MutableLiveData<>(null); + private final MutableLiveData currentTab = new MutableLiveData<>(Tab.RESULT); + private final MutableLiveData isCropped = new MutableLiveData<>(false); + private final MutableLiveData isTuned = new MutableLiveData<>(false); + private final MutableLiveData isFiltered = new MutableLiveData<>(false); + private final DocumentFile outputDir; + private List> tuningFilters; + private Filter appliedFilter; + private final DocumentFile destinationFile; + + public ImageEditViewModel(final Application application) { + super(application); + sessionId = LocalDateTime.now().format(SIMPLE_DATE_FORMAT); + outputDir = DownloadUtils.getImageEditDir(sessionId, application); + destinationFile = outputDir.createFile(MIME_TYPE, RESULT + ".jpg"); + destinationUri = destinationFile.getUri(); + cropDestinationUri = outputDir.createFile(MIME_TYPE, CROP + ".jpg").getUri(); + } + + public String getSessionId() { + return sessionId; + } + + public Uri getOriginalUri() { + return originalUri; + } + + public void setOriginalUri(final Uri originalUri) { + if (originalUri == null) return; + this.originalUri = originalUri; + savedImageEditState = new SavedImageEditState(sessionId, originalUri.toString()); + if (resultUri.getValue() == null) { + resultUri.postValue(originalUri); + } + } + + public Uri getDestinationUri() { + return destinationUri; + } + + public Uri getCropDestinationUri() { + return cropDestinationUri; + } + + public LiveData isLoading() { + return loading; + } + + public LiveData getResultUri() { + return resultUri; + } + + public LiveData isCropped() { + return isCropped; + } + + public LiveData isTuned() { + return isTuned; + } + + public LiveData isFiltered() { + return isFiltered; + } + + public void setResultUri(final Uri uri) { + if (uri == null) return; + resultUri.postValue(uri); + } + + public LiveData getCurrentTab() { + return currentTab; + } + + public void setCurrentTab(final Tab tab) { + if (tab == null) return; + this.currentTab.postValue(tab); + } + + public SavedImageEditState getSavedImageEditState() { + return savedImageEditState; + } + + public void setCropResult(final float[] imageMatrixValues, final RectF cropRect) { + savedImageEditState.setCropImageMatrixValues(imageMatrixValues); + savedImageEditState.setCropRect(cropRect); + isCropped.postValue(true); + applyFilters(); + } + + private void applyFilters() { + final GPUImage gpuImage = new GPUImage(getApplication()); + if ((tuningFilters != null && !tuningFilters.isEmpty()) || appliedFilter != null) { + AppExecutors.INSTANCE.getTasksThread().submit(() -> { + final List list = new ArrayList<>(); + if (tuningFilters != null) { + for (Filter tuningFilter : tuningFilters) { + list.add(tuningFilter.getInstance()); + } + } + if (appliedFilter != null) { + list.add(appliedFilter.getInstance()); + } + gpuImage.setFilter(new GPUImageFilterGroup(list)); + final Uri uri = cropDestinationUri != null ? cropDestinationUri : originalUri; + gpuImage.setImage(uri); + gpuImage.saveToPictures(new File(destinationUri.toString()), false, uri1 -> setResultUri(destinationUri)); + }); + return; + } + setResultUri(cropDestinationUri); + } + + public void cancel() { + delete(outputDir); + } + + private void delete(@NonNull final DocumentFile file) { + if (file.isDirectory()) { + final DocumentFile[] files = file.listFiles(); + if (files != null) { + for (DocumentFile f : files) { + delete(f); + } + } + } + file.delete(); + } + + public void setAppliedFilters(final List> tuningFilters, final Filter filter) { + this.tuningFilters = tuningFilters; + this.appliedFilter = filter; + if (savedImageEditState != null) { + final HashMap> tuningFiltersMap = new HashMap<>(); + for (final Filter tuningFilter : tuningFilters) { + final SerializablePair> filterValuesMap = getFilterValuesMap(tuningFilter); + tuningFiltersMap.put(filterValuesMap.first, filterValuesMap.second); + } + savedImageEditState.setAppliedTuningFilters(tuningFiltersMap); + savedImageEditState.setAppliedFilter(getFilterValuesMap(filter)); + } + isTuned.postValue(!tuningFilters.isEmpty()); + isFiltered.postValue(filter != null); + setResultUri(destinationUri); + } + + private SerializablePair> getFilterValuesMap(final Filter filter) { + if (filter == null) return null; + final FilterType type = filter.getType(); + final Map> properties = filter.getProperties(); + final Map propertyValueMap = new HashMap<>(); + if (properties != null) { + final Set>> entries = properties.entrySet(); + for (final Map.Entry> entry : entries) { + final Integer propId = entry.getKey(); + final Property property = entry.getValue(); + final Object value = property.getValue(); + propertyValueMap.put(propId, value); + } + } + return new SerializablePair<>(type, propertyValueMap); + } + + // public File getDestinationFile() { + // return destinationFile; + // } + + public enum Tab { + RESULT, + CROP, + TUNE, + FILTERS + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/MediaViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/MediaViewModel.java new file mode 100644 index 0000000..7cffed6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/MediaViewModel.java @@ -0,0 +1,108 @@ +package awais.instagrabber.viewmodels; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import java.util.ArrayList; +import java.util.List; + +import awais.instagrabber.customviews.helpers.PostFetcher; +import awais.instagrabber.fragments.settings.PreferenceKeys; +import awais.instagrabber.interfaces.FetchListener; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.KeywordsFilterUtilsKt; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class MediaViewModel extends ViewModel { + private static final String TAG = MediaViewModel.class.getSimpleName(); + + private boolean refresh = true; + + private final PostFetcher postFetcher; + private final MutableLiveData> list = new MutableLiveData<>(); + + public MediaViewModel(@NonNull final PostFetcher.PostFetchService postFetchService) { + final FetchListener> fetchListener = new FetchListener>() { + @Override + public void onResult(final List result) { + if (refresh) { + list.postValue(filterResult(result, true)); + refresh = false; + return; + } + list.postValue(filterResult(result, false)); + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + } + }; + postFetcher = new PostFetcher(postFetchService, fetchListener); + } + + @NonNull + private List filterResult(final List result, final boolean isRefresh) { + final List models = list.getValue(); + final List modelsCopy = models == null || isRefresh ? new ArrayList<>() : new ArrayList<>(models); + if (settingsHelper.getBoolean(PreferenceKeys.TOGGLE_KEYWORD_FILTER)) { + final List keywords = new ArrayList<>(settingsHelper.getStringSet(PreferenceKeys.KEYWORD_FILTERS)); + final List filter = KeywordsFilterUtilsKt.filter(keywords, result); + if (filter != null) { + modelsCopy.addAll(filter); + } + return modelsCopy; + } + modelsCopy.addAll(result); + return modelsCopy; + } + + public LiveData> getList() { + return list; + } + + public boolean hasMore() { + return postFetcher.hasMore(); + } + + public void fetch() { + postFetcher.fetch(); + } + + public void reset() { + postFetcher.reset(); + } + + public boolean isFetching() { + return postFetcher.isFetching(); + } + + public void refresh() { + refresh = true; + reset(); + fetch(); + } + + public static class ViewModelFactory implements ViewModelProvider.Factory { + + @NonNull + private final PostFetcher.PostFetchService postFetchService; + + public ViewModelFactory(@NonNull final PostFetcher.PostFetchService postFetchService) { + this.postFetchService = postFetchService; + } + + @NonNull + @Override + public T create(@NonNull final Class modelClass) { + //noinspection unchecked + return (T) new MediaViewModel(postFetchService); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/NotificationViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/NotificationViewModel.java new file mode 100644 index 0000000..1e3e984 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/NotificationViewModel.java @@ -0,0 +1,19 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.List; + +import awais.instagrabber.repositories.responses.notification.Notification; + +public class NotificationViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt new file mode 100644 index 0000000..3837a78 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt @@ -0,0 +1,351 @@ +package awais.instagrabber.viewmodels + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import awais.instagrabber.R +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Resource.Companion.error +import awais.instagrabber.models.Resource.Companion.loading +import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.models.enums.BroadcastItemType +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.repositories.responses.Caption +import awais.instagrabber.repositories.responses.Location +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.utils.getCsrfTokenFromCookie +import awais.instagrabber.utils.getUserIdFromCookie +import awais.instagrabber.webservices.MediaRepository +import com.google.common.collect.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* + +class PostViewV2ViewModel : ViewModel() { + private val user = MutableLiveData() + private val caption = MutableLiveData() + private val location = MutableLiveData() + private val date = MutableLiveData() + private val likeCount = MutableLiveData(0L) + private val commentCount = MutableLiveData(0L) + private val viewCount = MutableLiveData(0L) + private val type = MutableLiveData() + private val liked = MutableLiveData(false) + private val saved = MutableLiveData(false) + private val options = MutableLiveData>(ArrayList()) + private var messageManager: DirectMessagesManager? = null + private val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + private val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + private val csrfToken = getCsrfTokenFromCookie(cookie) + private val viewerId = getUserIdFromCookie(cookie) + private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } + + lateinit var media: Media + private set + val isLoggedIn = cookie.isNotBlank() && !csrfToken.isNullOrBlank() && viewerId != 0L + + fun setMedia(media: Media) { + this.media = media + user.postValue(media.user) + caption.postValue(media.caption) + location.postValue(media.location) + date.postValue(media.date) + likeCount.postValue(media.likeCount) + commentCount.postValue(media.commentCount) + viewCount.postValue(if (media.type == MediaItemType.MEDIA_TYPE_VIDEO) media.viewCount else null) + type.postValue(media.type) + liked.postValue(media.hasLiked) + saved.postValue(media.hasViewerSaved) + initOptions() + } + + private fun initOptions() { + val builder = ImmutableList.builder() + val user1 = media.user + if (isLoggedIn && user1 != null && user1.pk == viewerId) { + builder.add(R.id.edit_caption) + builder.add(R.id.delete) + } + options.postValue(builder.build()) + } + + fun getUser(): LiveData { + return user + } + + fun getCaption(): LiveData { + return caption + } + + fun getLocation(): LiveData { + return location + } + + fun getDate(): LiveData { + return date + } + + fun getLikeCount(): LiveData { + return likeCount + } + + fun getCommentCount(): LiveData { + return commentCount + } + + fun getViewCount(): LiveData { + return viewCount + } + + fun getType(): LiveData { + return type + } + + fun getLiked(): LiveData { + return liked + } + + fun getSaved(): LiveData { + return saved + } + + fun getOptions(): LiveData> { + return options + } + + fun toggleLike(): LiveData> { + return if (media.hasLiked) { + unlike() + } else like() + } + + fun like(): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + if (!isLoggedIn) { + data.postValue(error("Not logged in!", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + try { + val mediaId = media.pk ?: return@launch + val liked = mediaRepository.like(csrfToken!!, viewerId, deviceUuid, mediaId) + updateMediaLikeUnlike(data, liked) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun unlike(): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + if (!isLoggedIn) { + data.postValue(error("Not logged in!", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + try { + val mediaId = media.pk ?: return@launch + val unliked = mediaRepository.unlike(csrfToken!!, viewerId, deviceUuid, mediaId) + updateMediaLikeUnlike(data, unliked) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + private fun updateMediaLikeUnlike(data: MutableLiveData>, result: Boolean) { + if (!result) { + data.postValue(error("", null)) + return + } + data.postValue(success(true)) + val currentLikesCount = media.likeCount + val updatedCount: Long + if (!media.hasLiked) { + updatedCount = currentLikesCount + 1 + media.hasLiked = true + } else { + updatedCount = currentLikesCount - 1 + media.hasLiked = false + } + media.likeCount = updatedCount + likeCount.postValue(updatedCount) + liked.postValue(media.hasLiked) + } + + fun toggleSave(): LiveData> { + return if (!media.hasViewerSaved) { + save(null, false) + } else unsave() + } + + fun toggleSave(collection: String?, ignoreSaveState: Boolean): LiveData> { + return save(collection, ignoreSaveState) + } + + fun save(collection: String?, ignoreSaveState: Boolean): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + if (!isLoggedIn) { + data.postValue(error("Not logged in!", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + try { + val mediaId = media.pk ?: return@launch + val saved = mediaRepository.save(csrfToken!!, viewerId, deviceUuid, mediaId, collection) + getSaveUnsaveCallback(data, saved, ignoreSaveState) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun unsave(): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + if (!isLoggedIn) { + data.postValue(error("Not logged in!", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + val mediaId = media.pk ?: return@launch + val unsaved = mediaRepository.unsave(csrfToken!!, viewerId, deviceUuid, mediaId) + getSaveUnsaveCallback(data, unsaved, false) + } + return data + } + + private fun getSaveUnsaveCallback( + data: MutableLiveData>, + result: Boolean, + ignoreSaveState: Boolean, + ) { + if (!result) { + data.postValue(error("", null)) + return + } + data.postValue(success(true)) + if (!ignoreSaveState) media.hasViewerSaved = !media.hasViewerSaved + saved.postValue(media.hasViewerSaved) + } + + fun updateCaption(caption: String): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + if (!isLoggedIn) { + data.postValue(error("Not logged in!", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + try { + val postId = media.pk ?: return@launch + val result = mediaRepository.editCaption(csrfToken!!, viewerId, deviceUuid, postId, caption) + if (result) { + data.postValue(success("")) + media.setPostCaption(caption) + this@PostViewV2ViewModel.caption.postValue(media.caption) + return@launch + } + data.postValue(error("", null)) + } catch (e: Exception) { + Log.e(TAG, "Error editing caption", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun translateCaption(): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val value = caption.value + val pk = value?.pk + if (pk == null) { + data.postValue(error("caption is null", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + try { + val result = mediaRepository.translate(pk, "1") ?: return@launch + if (result.isBlank()) { + // data.postValue(error("", null)) + return@launch + } + data.postValue(success(result)) + } catch (e: Exception) { + Log.e(TAG, "Error translating comment", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun hasPk(): Boolean { + return media.pk != null + } + + fun setViewCount(viewCount: Long?) { + this.viewCount.postValue(viewCount) + } + + fun delete(): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + if (!isLoggedIn) { + data.postValue(error("Not logged in!", null)) + return data + } + val mediaId = media.id + val mediaType = media.type + if (mediaId == null || mediaType == null) { + data.postValue(error("media id or type is null", null)) + return data + } + viewModelScope.launch(Dispatchers.IO) { + try { + val response = mediaRepository.delete(csrfToken!!, viewerId, deviceUuid, mediaId, mediaType) + if (response == null) { + data.postValue(success(Any())) + return@launch + } + data.postValue(success(Any())) + } catch (e: Exception) { + Log.e(TAG, "delete: ", e) + data.postValue(error(e.message, null)) + } + } + return data + } + + fun shareDm(result: RankedRecipient, child: Int) { + if (messageManager == null) { + messageManager = DirectMessagesManager + } + val mediaId = media.id ?: return + val childId = if (child == -1) null else media.carouselMedia?.get(child)?.id + messageManager?.sendMedia(result, mediaId, childId, BroadcastItemType.MEDIA_SHARE, viewModelScope) + } + + fun shareDm(recipients: Set, child: Int) { + if (messageManager == null) { + messageManager = DirectMessagesManager + } + val mediaId = media.id ?: return + val childId = if (child == -1) null else media.carouselMedia?.get(child)?.id + messageManager?.sendMedia(recipients, mediaId, childId, BroadcastItemType.MEDIA_SHARE, viewModelScope) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt new file mode 100644 index 0000000..b3a7527 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt @@ -0,0 +1,634 @@ +package awais.instagrabber.viewmodels + +import android.os.Bundle +import android.util.Log +import androidx.lifecycle.* +import androidx.savedstate.SavedStateRegistryOwner +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.db.repositories.FavoriteRepository +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.models.Resource +import awais.instagrabber.models.enums.BroadcastItemType +import awais.instagrabber.models.enums.FavoriteType +import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.FriendshipStatus +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.UserProfileContextLink +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.repositories.responses.stories.Story +import awais.instagrabber.utils.ControlledRunner +import awais.instagrabber.utils.Event +import awais.instagrabber.utils.SingleRunner +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.utils.extensions.isReallyPrivate +import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileAction.* +import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileEvent.* +import awais.instagrabber.webservices.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +class ProfileFragmentViewModel( + private val state: SavedStateHandle, + private val csrfToken: String?, + private val deviceUuid: String?, + private val userRepository: UserRepository, + private val friendshipRepository: FriendshipRepository, + private val storiesRepository: StoriesRepository, + private val mediaRepository: MediaRepository, + private val graphQLRepository: GraphQLRepository, + private val favoriteRepository: FavoriteRepository, + private val directMessagesRepository: DirectMessagesRepository, + private val messageManager: DirectMessagesManager?, + ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + private val _currentUser = MutableLiveData>(Resource.loading(null)) + private val _isFavorite = MutableLiveData(false) + private val profileAction = MutableLiveData(INIT) + private val _eventLiveData = MutableLiveData?>() + + private var previousUsername: String? = null + + enum class ProfileAction { + INIT, + REFRESH, + REFRESH_FRIENDSHIP, + } + + sealed class ProfileEvent { + object ShowConfirmUnfollowDialog : ProfileEvent() + class DMButtonState(val disabled: Boolean) : ProfileEvent() + class NavigateToThread(val threadId: String, val username: String) : ProfileEvent() + class ShowTranslation(val result: String) : ProfileEvent() + } + + val currentUser: LiveData> = _currentUser + val isLoggedIn: LiveData = currentUser.map { it.data != null } + val isFavorite: LiveData = _isFavorite + val eventLiveData: LiveData?> = _eventLiveData + + private val currentUserStateUsernameActionLiveData: LiveData, Resource, ProfileAction>> = + object : MediatorLiveData, Resource, ProfileAction>>() { + var user: Resource = Resource.loading(null) + var stateUsername: Resource = Resource.loading(null) + var action: ProfileAction = INIT + + init { + addSource(currentUser) { currentUser -> + this.user = currentUser + value = Triple(currentUser, stateUsername, action) + } + addSource(state.getLiveData("username")) { username -> + this.stateUsername = Resource.success(username.substringAfter('@')) + value = Triple(user, this.stateUsername, action) + } + addSource(profileAction) { action -> + this.action = action + value = Triple(user, stateUsername, action) + } + // trigger currentUserStateUsernameActionLiveData switch map with a state username success resource + if (!state.contains("username")) { + this.stateUsername = Resource.success(null) + value = Triple(user, this.stateUsername, action) + } + } + } + + private val profileFetchControlledRunner = ControlledRunner() + val profile: LiveData> = currentUserStateUsernameActionLiveData.switchMap { + val (currentUserResource, stateUsernameResource, action) = it + liveData>(context = viewModelScope.coroutineContext + ioDispatcher) { + if (action == INIT && previousUsername != null && stateUsernameResource.data == previousUsername) return@liveData + if (currentUserResource.status == Resource.Status.LOADING || stateUsernameResource.status == Resource.Status.LOADING) { + emit(Resource.loading(profileCopy.value?.data)) + return@liveData + } + val currentUser = currentUserResource.data + val stateUsername = stateUsernameResource.data + if (stateUsername.isNullOrBlank()) { + emit(Resource.success(currentUser)) + return@liveData + } + try { + when (action) { + INIT, REFRESH -> { + previousUsername = stateUsername + val fetchedUser = profileFetchControlledRunner.cancelPreviousThenRun { fetchUser(currentUser, stateUsername) } + emit(Resource.success(fetchedUser)) + if (fetchedUser != null) { + checkAndUpdateFavorite(fetchedUser) + } + } + REFRESH_FRIENDSHIP -> { + var profile = profileCopy.value?.data ?: return@liveData + profile = profile.copy(friendshipStatus = userRepository.getUserFriendship(profile.pk)) + emit(Resource.success(profile)) + } + } + } catch (e: Exception) { + emit(Resource.error(e.message, profileCopy.value?.data)) + Log.e(TAG, "fetching user: ", e) + } + } + } + val profileCopy = profile + + val currentUserProfileActionLiveData: LiveData, Resource, ProfileAction>> = + object : MediatorLiveData, Resource, ProfileAction>>() { + var currentUser: Resource = Resource.loading(null) + var profile: Resource = Resource.loading(null) + var action: ProfileAction = INIT + + init { + addSource(this@ProfileFragmentViewModel.currentUser) { currentUser -> + this.currentUser = currentUser + value = Triple(currentUser, profile, action) + } + addSource(this@ProfileFragmentViewModel.profile) { profile -> + this.profile = profile + value = Triple(currentUser, this.profile, action) + } + addSource(profileAction) { action -> + this.action = action + value = Triple(currentUser, this.profile, action) + } + } + } + + private val storyFetchControlledRunner = ControlledRunner() + val userStories: LiveData> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair -> + liveData>(context = viewModelScope.coroutineContext + ioDispatcher) { + val (currentUserResource, profileResource, action) = currentUserAndProfilePair + if (action != INIT && action != REFRESH) { + return@liveData + } + // don't fetch if not logged in + if (currentUserResource.data == null) { + emit(Resource.success(null)) + return@liveData + } + if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { + emit(Resource.loading(null)) + return@liveData + } + val user = profileResource.data + if (user == null) { + emit(Resource.success(null)) + return@liveData + } + try { + val fetchedStories = storyFetchControlledRunner.cancelPreviousThenRun { fetchUserStory(user) } + emit(Resource.success(fetchedStories)) + } catch (e: Exception) { + emit(Resource.error(e.message, null)) + Log.e(TAG, "fetching story: ", e) + } + } + } + + private val highlightsFetchControlledRunner = ControlledRunner?>() + val userHighlights: LiveData?>> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair -> + liveData?>>(context = viewModelScope.coroutineContext + ioDispatcher) { + val (currentUserResource, profileResource, action) = currentUserAndProfilePair + if (action != INIT && action != REFRESH) { + return@liveData + } + // don't fetch if not logged in + if (currentUserResource.data == null) { + emit(Resource.success(null)) + return@liveData + } + if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { + emit(Resource.loading(null)) + return@liveData + } + val user = profileResource.data + if (user == null) { + emit(Resource.success(null)) + return@liveData + } + try { + val fetchedHighlights = highlightsFetchControlledRunner.cancelPreviousThenRun { fetchUserHighlights(user) } + emit(Resource.success(fetchedHighlights)) + } catch (e: Exception) { + emit(Resource.error(e.message, null)) + Log.e(TAG, "fetching highlights: ", e) + } + } + } + + private suspend fun fetchUser( + currentUser: User?, + stateUsername: String, + ): User? { + if (currentUser != null) { + // logged in + val tempUser = userRepository.getUsernameInfo(stateUsername) + if (!tempUser.isReallyPrivate(currentUser)) { + tempUser.friendshipStatus = userRepository.getUserFriendship(tempUser.pk) + } + return tempUser + } + // anonymous + return graphQLRepository.fetchUser(stateUsername) + } + + private suspend fun fetchUserStory(fetchedUser: User): Story? = storiesRepository.getStories( + StoryViewerOptions.forUser(fetchedUser.pk, fetchedUser.fullName) + ) + + private suspend fun fetchUserHighlights(fetchedUser: User): List = storiesRepository.fetchHighlights(fetchedUser.pk) + + private suspend fun checkAndUpdateFavorite(fetchedUser: User) { + try { + val favorite = favoriteRepository.getFavorite(fetchedUser.username, FavoriteType.USER) + if (favorite == null) { + _isFavorite.postValue(false) + return + } + _isFavorite.postValue(true) + favoriteRepository.insertOrUpdateFavorite( + Favorite( + favorite.id, + fetchedUser.username, + FavoriteType.USER, + fetchedUser.fullName, + fetchedUser.profilePicUrl, + favorite.dateAdded + ) + ) + } catch (e: Exception) { + _isFavorite.postValue(false) + Log.e(TAG, "checkAndUpdateFavorite: ", e) + } + } + + fun setCurrentUser(currentUser: Resource) { + _currentUser.postValue(currentUser) + } + + fun shareDm(result: RankedRecipient) { + val mediaId = profile.value?.data?.pk ?: return + messageManager?.sendMedia(result, mediaId.toString(10), null, BroadcastItemType.PROFILE, viewModelScope) + } + + fun shareDm(recipients: Set) { + val mediaId = profile.value?.data?.pk ?: return + messageManager?.sendMedia(recipients, mediaId.toString(10), null, BroadcastItemType.PROFILE, viewModelScope) + } + + fun refresh() { + profileAction.postValue(REFRESH) + } + + private val toggleFavoriteControlledRunner = SingleRunner() + fun toggleFavorite() { + val username = profile.value?.data?.username ?: return + val fullName = profile.value?.data?.fullName ?: return + val profilePicUrl = profile.value?.data?.profilePicUrl ?: return + viewModelScope.launch(Dispatchers.IO) { + toggleFavoriteControlledRunner.afterPrevious { + try { + val favorite = favoriteRepository.getFavorite(username, FavoriteType.USER) + if (favorite == null) { + // insert + favoriteRepository.insertOrUpdateFavorite( + Favorite( + 0, + username, + FavoriteType.USER, + fullName, + profilePicUrl, + LocalDateTime.now() + ) + ) + _isFavorite.postValue(true) + return@afterPrevious + } + // delete + favoriteRepository.deleteFavorite(username, FavoriteType.USER) + _isFavorite.postValue(false) + } catch (e: Exception) { + Log.e(TAG, "checkAndUpdateFavorite: ", e) + } + } + } + } + + private val toggleFollowSingleRunner = SingleRunner() + fun toggleFollow(confirmed: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + toggleFollowSingleRunner.afterPrevious { + try { + val following = profile.value?.data?.friendshipStatus?.following ?: false + val currentUserId = currentUser.value?.data?.pk ?: return@afterPrevious + val targetUserId = profile.value?.data?.pk ?: return@afterPrevious + val csrfToken = csrfToken ?: return@afterPrevious + val deviceUuid = deviceUuid ?: return@afterPrevious + if (following) { + if (!confirmed) { + _eventLiveData.postValue(Event(ShowConfirmUnfollowDialog)) + return@afterPrevious + } + // unfollow + friendshipRepository.unfollow( + csrfToken, + currentUserId, + deviceUuid, + targetUserId + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + return@afterPrevious + } + friendshipRepository.follow( + csrfToken, + currentUserId, + deviceUuid, + targetUserId + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "toggleFollow: ", e) + } + } + } + } + + private val sendDmSingleRunner = SingleRunner() + fun sendDm() { + viewModelScope.launch(Dispatchers.IO) { + sendDmSingleRunner.afterPrevious { + _eventLiveData.postValue(Event(DMButtonState(true))) + try { + val currentUserId = currentUser.value?.data?.pk ?: return@afterPrevious + val targetUserId = profile.value?.data?.pk ?: return@afterPrevious + val csrfToken = csrfToken ?: return@afterPrevious + val deviceUuid = deviceUuid ?: return@afterPrevious + val username = profile.value?.data?.username ?: return@afterPrevious + val thread = directMessagesRepository.createThread( + csrfToken, + currentUserId, + deviceUuid, + listOf(targetUserId), + null, + ) + val inboxManager = DirectMessagesManager.inboxManager + if (!inboxManager.containsThread(thread.threadId)) { + thread.isTemp = true + inboxManager.addThread(thread, 0) + } + val threadId = thread.threadId ?: return@afterPrevious + _eventLiveData.postValue(Event(NavigateToThread(threadId, username))) + delay(200) // Add delay so that the postValue in finally does not overwrite the NavigateToThread event + } catch (e: Exception) { + Log.e(TAG, "sendDm: ", e) + } finally { + _eventLiveData.postValue(Event(DMButtonState(false))) + } + } + } + } + + private val restrictUserSingleRunner = SingleRunner() + fun restrictUser() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + restrictUserSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.toggleRestrict( + csrfToken ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.pk, + !(profile.friendshipStatus?.isRestricted ?: false), + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "restrictUser: ", e) + } + } + } + } + + private val blockUserSingleRunner = SingleRunner() + fun blockUser() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + blockUserSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.changeBlock( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.friendshipStatus?.blocking ?: return@afterPrevious, + profile.pk + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "blockUser: ", e) + } + } + } + } + + private val muteStoriesSingleRunner = SingleRunner() + fun muteStories() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + muteStoriesSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.changeMute( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.friendshipStatus?.isMutingReel ?: return@afterPrevious, + profile.pk, + true + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "muteStories: ", e) + } + } + } + } + + private val mutePostsSingleRunner = SingleRunner() + fun mutePosts() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + mutePostsSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.changeMute( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.friendshipStatus?.muting ?: return@afterPrevious, + profile.pk, + false + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "mutePosts: ", e) + } + } + } + } + + private val removeFollowerSingleRunner = SingleRunner() + fun removeFollower() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + removeFollowerSingleRunner.afterPrevious { + try { + friendshipRepository.removeFollower( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.value?.data?.pk ?: return@afterPrevious + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "removeFollower: ", e) + } + } + } + } + + private val translateBioSingleRunner = SingleRunner() + fun translateBio() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + translateBioSingleRunner.afterPrevious { + try { + val result = mediaRepository.translate( + profile.value?.data?.pk?.toString() ?: return@afterPrevious, + "3" + ) + if (result.isNullOrBlank()) return@afterPrevious + _eventLiveData.postValue(Event(ShowTranslation(result))) + } catch (e: Exception) { + Log.e(TAG, "translateBio: ", e) + } + } + } + } + + /** + * Username of profile without '`@`' + */ + val username: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.username ?: "" + } + } + val profilePicUrl: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.profilePicUrl + } + } + val fullName: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.fullName + } + } + val biography: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.biography + } + } + val url: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.externalUrl + } + } + val followersCount: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.followerCount + } + } + val followingCount: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.followingCount + } + } + val postCount: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.mediaCount + } + } + val isPrivate: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.isPrivate + } + } + val isVerified: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.isVerified + } + } + val friendshipStatus: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.friendshipStatus + } + } + val profileContext: LiveData?>> = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null to null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.profileContext to it.data?.profileContextLinksWithUserIds + } + } +} + +@Suppress("UNCHECKED_CAST") +class ProfileFragmentViewModelFactory( + private val csrfToken: String?, + private val deviceUuid: String?, + private val userRepository: UserRepository, + private val friendshipRepository: FriendshipRepository, + private val storiesRepository: StoriesRepository, + private val mediaRepository: MediaRepository, + private val graphQLRepository: GraphQLRepository, + private val favoriteRepository: FavoriteRepository, + private val directMessagesRepository: DirectMessagesRepository, + private val messageManager: DirectMessagesManager?, + owner: SavedStateRegistryOwner, + defaultArgs: Bundle? = null, +) : AbstractSavedStateViewModelFactory(owner, defaultArgs) { + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle, + ): T { + return ProfileFragmentViewModel( + handle, + csrfToken, + deviceUuid, + userRepository, + friendshipRepository, + storiesRepository, + mediaRepository, + graphQLRepository, + favoriteRepository, + directMessagesRepository, + messageManager, + Dispatchers.IO, + ) as T + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java new file mode 100644 index 0000000..4f245f3 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java @@ -0,0 +1,19 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.List; + +import awais.instagrabber.repositories.responses.saved.SavedCollection; + +public class SavedCollectionsViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java new file mode 100644 index 0000000..4582529 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java @@ -0,0 +1,365 @@ +package awais.instagrabber.viewmodels; + +import android.app.Application; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.db.datasources.RecentSearchDataSource; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.db.repositories.FavoriteRepository; +import awais.instagrabber.db.repositories.RecentSearchRepository; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; +import awais.instagrabber.repositories.responses.search.SearchResponse; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.SearchService; +import kotlinx.coroutines.Dispatchers; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static androidx.lifecycle.Transformations.distinctUntilChanged; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SearchFragmentViewModel extends AppStateViewModel { + private static final String TAG = SearchFragmentViewModel.class.getSimpleName(); + private static final String QUERY = "query"; + + private final MutableLiveData query = new MutableLiveData<>(); + private final MutableLiveData>> topResults = new MutableLiveData<>(); + private final MutableLiveData>> userResults = new MutableLiveData<>(); + private final MutableLiveData>> hashtagResults = new MutableLiveData<>(); + private final MutableLiveData>> locationResults = new MutableLiveData<>(); + + private final SearchService searchService; + private final Debouncer searchDebouncer; + private final boolean isLoggedIn; + private final LiveData distinctQuery; + private final RecentSearchRepository recentSearchRepository; + private final FavoriteRepository favoriteRepository; + + private String tempQuery; + + public SearchFragmentViewModel(@NonNull final Application application) { + super(application); + final String cookie = settingsHelper.getString(Constants.COOKIE); + isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; + final Debouncer.Callback searchCallback = new Debouncer.Callback() { + @Override + public void call(final String key) { + if (tempQuery == null) return; + query.postValue(tempQuery); + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "onError: ", t); + } + }; + searchDebouncer = new Debouncer<>(searchCallback, 500); + distinctQuery = distinctUntilChanged(query); + searchService = SearchService.getInstance(); + recentSearchRepository = RecentSearchRepository.getInstance(RecentSearchDataSource.getInstance(application)); + favoriteRepository = FavoriteRepository.Companion.getInstance(application); + } + + public LiveData getQuery() { + return distinctQuery; + } + + public LiveData>> getTopResults() { + return topResults; + } + + public LiveData>> getUserResults() { + return userResults; + } + + public LiveData>> getHashtagResults() { + return hashtagResults; + } + + public LiveData>> getLocationResults() { + return locationResults; + } + + public void submitQuery(@Nullable final String query) { + String localQuery = query; + if (query == null) { + localQuery = ""; + } + if (tempQuery != null && Objects.equals(localQuery.toLowerCase(), tempQuery.toLowerCase())) return; + tempQuery = query; + if (TextUtils.isEmpty(query)) { + // If empty immediately post it + searchDebouncer.cancel(QUERY); + this.query.postValue(""); + return; + } + searchDebouncer.call(QUERY); + } + + public void search(@NonNull final String query, + @NonNull final FavoriteType type) { + final MutableLiveData>> liveData = getLiveDataByType(type); + if (liveData == null) return; + if (TextUtils.isEmpty(query)) { + showRecentSearchesAndFavorites(type, liveData); + return; + } + if (query.equals("@") || query.equals("#")) return; + final String c; + switch (type) { + case TOP: + c = "blended"; + break; + case USER: + c = "user"; + break; + case HASHTAG: + c = "hashtag"; + break; + case LOCATION: + c = "place"; + break; + default: + return; + } + liveData.postValue(Resource.loading(null)); + final Call request = searchService.search(isLoggedIn, query, c); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, + @NonNull final Response response) { + if (!response.isSuccessful()) { + sendErrorResponse(type); + return; + } + final SearchResponse body = response.body(); + if (body == null) { + sendErrorResponse(type); + return; + } + parseResponse(body, type); + } + + @Override + public void onFailure(@NonNull final Call call, + @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + } + }); + } + + private void showRecentSearchesAndFavorites(@NonNull final FavoriteType type, + @NonNull final MutableLiveData>> liveData) { + final SettableFuture> recentResultsFuture = SettableFuture.create(); + final SettableFuture> favoritesFuture = SettableFuture.create(); + recentSearchRepository.getAllRecentSearches( + CoroutineUtilsKt.getContinuation((recentSearches, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "showRecentSearchesAndFavorites: ", throwable); + recentResultsFuture.set(Collections.emptyList()); + return; + } + if (type != FavoriteType.TOP) { + recentResultsFuture.set((List) recentSearches + .stream() + .filter(rs -> rs.getType() == type) + .collect(Collectors.toList()) + ); + return; + } + //noinspection unchecked + recentResultsFuture.set((List) recentSearches); + }), Dispatchers.getIO()) + ); + favoriteRepository.getAllFavorites( + CoroutineUtilsKt.getContinuation((favorites, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + favoritesFuture.set(Collections.emptyList()); + Log.e(TAG, "showRecentSearchesAndFavorites: ", throwable); + return; + } + if (type != FavoriteType.TOP) { + favoritesFuture.set((List) favorites + .stream() + .filter(f -> f.getType() == type) + .collect(Collectors.toList()) + ); + return; + } + //noinspection unchecked + favoritesFuture.set((List) favorites); + }), Dispatchers.getIO()) + ); + //noinspection UnstableApiUsage + final ListenableFuture>> listenableFuture = Futures.allAsList(recentResultsFuture, favoritesFuture); + Futures.addCallback(listenableFuture, new FutureCallback>>() { + @Override + public void onSuccess(@Nullable final List> result) { + if (!TextUtils.isEmpty(tempQuery)) return; // Make sure user has not entered anything before updating results + if (result == null) { + liveData.postValue(Resource.success(Collections.emptyList())); + return; + } + try { + //noinspection unchecked + liveData.postValue(Resource.success( + ImmutableList.builder() + .addAll(SearchItem.fromRecentSearch((List) result.get(0))) + .addAll(SearchItem.fromFavorite((List) result.get(1))) + .build() + )); + } catch (Exception e) { + Log.e(TAG, "onSuccess: ", e); + liveData.postValue(Resource.success(Collections.emptyList())); + } + } + + @Override + public void onFailure(@NonNull final Throwable t) { + if (!TextUtils.isEmpty(tempQuery)) return; + liveData.postValue(Resource.success(Collections.emptyList())); + Log.e(TAG, "onFailure: ", t); + } + }, AppExecutors.INSTANCE.getMainThread()); + } + + private void sendErrorResponse(@NonNull final FavoriteType type) { + final MutableLiveData>> liveData = getLiveDataByType(type); + if (liveData == null) return; + liveData.postValue(Resource.error(null, Collections.emptyList())); + } + + private MutableLiveData>> getLiveDataByType(@NonNull final FavoriteType type) { + final MutableLiveData>> liveData; + switch (type) { + case TOP: + liveData = topResults; + break; + case USER: + liveData = userResults; + break; + case HASHTAG: + liveData = hashtagResults; + break; + case LOCATION: + liveData = locationResults; + break; + default: + return null; + } + return liveData; + } + + private void parseResponse(@NonNull final SearchResponse body, + @NonNull final FavoriteType type) { + final MutableLiveData>> liveData = getLiveDataByType(type); + if (liveData == null) return; + if (isLoggedIn) { + if (body.getList() == null) { + liveData.postValue(Resource.success(Collections.emptyList())); + return; + } + if (type == FavoriteType.HASHTAG || type == FavoriteType.LOCATION) { + liveData.postValue(Resource.success(body.getList() + .stream() + .filter(i -> i.getUser() == null) + .collect(Collectors.toList()))); + return; + } + liveData.postValue(Resource.success(body.getList())); + return; + } + + // anonymous + final List list; + switch (type) { + case TOP: + list = ImmutableList + .builder() + .addAll(body.getUsers() == null ? Collections.emptyList() : body.getUsers()) + .addAll(body.getHashtags() == null ? Collections.emptyList() : body.getHashtags()) + .addAll(body.getPlaces() == null ? Collections.emptyList() : body.getPlaces()) + .build(); + break; + case USER: + list = body.getUsers(); + break; + case HASHTAG: + list = body.getHashtags(); + break; + case LOCATION: + list = body.getPlaces(); + break; + default: + return; + } + liveData.postValue(Resource.success(list)); + } + + public void saveToRecentSearches(final SearchItem searchItem) { + if (searchItem == null) return; + try { + final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); + if (recentSearch == null) return; + recentSearchRepository.insertOrUpdateRecentSearch( + recentSearch, + CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "saveToRecentSearches: ", throwable); + // return; + } + // Log.d(TAG, "onSuccess: inserted recent: " + recentSearch); + }), Dispatchers.getIO()) + ); + } catch (Exception e) { + Log.e(TAG, "saveToRecentSearches: ", e); + } + } + + @Nullable + public LiveData> deleteRecentSearch(final SearchItem searchItem) { + if (searchItem == null || !searchItem.isRecent()) return null; + final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); + if (recentSearch == null) return null; + final MutableLiveData> data = new MutableLiveData<>(); + data.postValue(Resource.loading(null)); + recentSearchRepository.deleteRecentSearchByIgIdAndType( + recentSearch.getIgId(), + recentSearch.getType(), + CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable != null) { + Log.e(TAG, "deleteRecentSearch: ", throwable); + data.postValue(Resource.error("Error deleting recent item", null)); + return; + } + data.postValue(Resource.success(new Object())); + }), Dispatchers.getIO()) + ); + return data; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/StoryFragmentViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/StoryFragmentViewModel.kt new file mode 100644 index 0000000..ca4f3c6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/StoryFragmentViewModel.kt @@ -0,0 +1,499 @@ +package awais.instagrabber.viewmodels + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import awais.instagrabber.R +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.models.Resource +import awais.instagrabber.models.Resource.Companion.error +import awais.instagrabber.models.Resource.Companion.loading +import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.models.enums.BroadcastItemType +import awais.instagrabber.models.enums.FavoriteType +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.models.enums.StoryPaginationType +import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.repositories.responses.stories.* +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.getCsrfTokenFromCookie +import awais.instagrabber.utils.getUserIdFromCookie +import awais.instagrabber.webservices.MediaRepository +import awais.instagrabber.webservices.StoriesRepository +import com.google.common.collect.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class StoryFragmentViewModel : ViewModel() { + // large data + private val currentStory = MutableLiveData() + private val currentMedia = MutableLiveData() + + // small data + private val storyTitle = MutableLiveData() + private val date = MutableLiveData() + private val type = MutableLiveData() + private val poll = MutableLiveData() + private val quiz = MutableLiveData() + private val question = MutableLiveData() + private val slider = MutableLiveData() + private val swipeUp = MutableLiveData() + private val linkedPost = MutableLiveData() + private val appAttribution = MutableLiveData() + private val reelMentions = MutableLiveData>>() + + // process + private val currentIndex = MutableLiveData() + private val pagination = MutableLiveData(StoryPaginationType.DO_NOTHING) + private val options = MutableLiveData>, String?, String?>>() + private val seen = MutableLiveData>() + + // utils + private var messageManager: DirectMessagesManager? = null + private val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + private val deviceId = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + private val csrfToken = getCsrfTokenFromCookie(cookie) + private val userId = getUserIdFromCookie(cookie) + private val storiesRepository: StoriesRepository by lazy { StoriesRepository.getInstance() } + private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } + + // for highlights ONLY + val highlights = MutableLiveData?>() + + /* set functions */ + + fun setStory(story: Story) { + currentStory.postValue(story) + storyTitle.postValue(story.title ?: story.user?.username) + if (story.broadcast != null) { + date.postValue(story.dateTime) + type.postValue(MediaItemType.MEDIA_TYPE_LIVE) + pagination.postValue(StoryPaginationType.DO_NOTHING) + return + } + if (story.items == null || story.items.size == 0) { + pagination.postValue(StoryPaginationType.ERROR) + return + } + } + + fun setMedia(index: Int) { + if (currentStory.value?.items == null) return + if (index < 0 || index >= currentStory.value!!.items!!.size) { + pagination.postValue(if (index < 0) StoryPaginationType.BACKWARD else StoryPaginationType.FORWARD) + return + } + currentIndex.postValue(index) + val story: Story? = currentStory.value + val media = story!!.items!!.get(index) + currentMedia.postValue(media) + date.postValue(media.date) + type.postValue(media.type) + initStickers(media) + } + + fun setSingleMedia(media: StoryMedia) { + currentStory.postValue(null) + currentIndex.postValue(0) + currentMedia.postValue(media) + date.postValue(media.date) + type.postValue(media.type) + } + + private fun initStickers(media: StoryMedia) { + val builder = ImmutableList.builder>() + var linkedText: String? = null + var appText: String? = null + if (setMentions(media)) builder.add(Pair(R.id.mentions, R.string.story_mentions)) + if (setQuiz(media)) builder.add(Pair(R.id.quiz, R.string.story_quiz)) + if (setQuestion(media)) builder.add(Pair(R.id.question, R.string.story_question)) + if (setPoll(media)) builder.add(Pair(R.id.poll, R.string.story_poll)) + if (setSlider(media)) builder.add(Pair(R.id.slider, R.string.story_slider)) + if (setLinkedPost(media)) builder.add(Pair(R.id.viewStoryPost, R.string.view_post)) + if (setStoryCta(media)) { + linkedText = media.linkText + builder.add(Pair(R.id.swipeUp, 0)) + } + if (setStoryAppAttribution(media)) { + appText = media.storyAppAttribution!!.appActionText + builder.add(Pair(R.id.spotify, 0)) + } + options.postValue(Triple(builder.build(), linkedText, appText)) + } + + private fun setMentions(media: StoryMedia): Boolean { + val mentions: MutableList> = mutableListOf() + if (media.reelMentions != null) + mentions.addAll(media.reelMentions.map{ + Triple("@" + it.user?.username, it.user?.username, FavoriteType.USER) + }) + if (media.storyHashtags != null) + mentions.addAll(media.storyHashtags.map{ + Triple("#" + it.hashtag?.name, it.hashtag?.name, FavoriteType.HASHTAG) + }) + if (media.storyLocations != null) + mentions.addAll(media.storyLocations.map{ + Triple(it.location?.name ?: "", it.location?.pk?.toString(10), FavoriteType.LOCATION) + }) + reelMentions.postValue(mentions.filterNot { it.second.isNullOrEmpty() } .distinct()) + return !mentions.isEmpty() + } + + private fun setPoll(media: StoryMedia): Boolean { + poll.postValue(media.storyPolls?.get(0)?.pollSticker ?: return false) + return true + } + + private fun setQuiz(media: StoryMedia): Boolean { + quiz.postValue(media.storyQuizs?.get(0)?.quizSticker ?: return false) + return true + } + + private fun setQuestion(media: StoryMedia): Boolean { + val questionSticker = media.storyQuestions?.get(0)?.questionSticker ?: return false + if (questionSticker.questionType.equals("music")) return false + question.postValue(questionSticker) + return true + } + + private fun setSlider(media: StoryMedia): Boolean { + slider.postValue(media.storySliders?.get(0)?.sliderSticker ?: return false) + return true + } + + private fun setLinkedPost(media: StoryMedia): Boolean { + linkedPost.postValue(media.storyFeedMedia?.get(0)?.mediaId ?: return false) + return true + } + + private fun setStoryCta(media: StoryMedia): Boolean { + val webUri = media.storyCta?.get(0)?.links?.get(0)?.webUri ?: return false + val parsedUri = Uri.parse(webUri) + val cleanUri = if (parsedUri.host.equals("l.instagram.com")) parsedUri.getQueryParameter("u") + else null + swipeUp.postValue(if (cleanUri != null && Uri.parse(cleanUri).scheme?.startsWith("http") == true) cleanUri + else webUri) + return true + } + + private fun setStoryAppAttribution(media: StoryMedia): Boolean { + appAttribution.postValue(media.storyAppAttribution ?: return false) + return true + } + + /* get functions */ + + fun getCurrentStory(): LiveData { + return currentStory + } + + fun getCurrentIndex(): LiveData { + return currentIndex + } + + fun getCurrentMedia(): LiveData { + return currentMedia + } + + fun getPagination(): LiveData { + return pagination + } + + fun getDate(): LiveData { + return date + } + + fun getTitle(): LiveData { + return storyTitle + } + + fun getType(): LiveData { + return type + } + + fun getMedia(): LiveData { + return currentMedia + } + + fun getMention(index: Int): Triple? { + return reelMentions.value?.get(index) + } + + fun getMentionTexts(): Array { + return reelMentions.value!!.map { it.first } .toTypedArray() + } + + fun getPoll(): LiveData { + return poll + } + + fun getQuestion(): LiveData { + return question + } + + fun getQuiz(): LiveData { + return quiz + } + + fun getSlider(): LiveData { + return slider + } + + fun getLinkedPost(): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val postId = linkedPost.value + if (postId == null) data.postValue(error("No post ID supplied", null)) + else viewModelScope.launch(Dispatchers.IO) { + try { + val media = mediaRepository.fetch(postId.toLong()) + data.postValue(success(media)) + } + catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun getSwipeUp(): String? { + return swipeUp.value + } + + fun getAppAttribution(): String? { + return appAttribution.value?.url + } + + fun getOptions(): LiveData>, String?, String?>> { + return options + } + + /* action functions */ + + fun answerPoll(w: Int): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val oldPoll: PollSticker = poll.value!! + val response = storiesRepository.respondToPoll( + csrfToken!!, + userId, + deviceId, + currentMedia.value!!.pk, + oldPoll.pollId, + w + ) + if (!"ok".equals(response.status)) + throw Exception("Instagram returned status \"" + response.status + "\"") + val tally = oldPoll.tallies.get(w) + val newTally = tally.copy(count = tally.count + 1) + val newTallies = oldPoll.tallies.toMutableList() + newTallies.set(w, newTally) + poll.postValue(oldPoll.copy(viewerVote = w, tallies = newTallies.toList())) + data.postValue(success(null)) + } + catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun answerQuiz(w: Int): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val oldQuiz = quiz.value!! + val response = storiesRepository.respondToQuiz( + csrfToken!!, + userId, + deviceId, + currentMedia.value!!.pk, + oldQuiz.quizId, + w + ) + if (!"ok".equals(response.status)) + throw Exception("Instagram returned status \"" + response.status + "\"") + val tally = oldQuiz.tallies.get(w) + val newTally = tally.copy(count = tally.count + 1) + val newTallies = oldQuiz.tallies.toMutableList() + newTallies.set(w, newTally) + quiz.postValue(oldQuiz.copy(viewerAnswer = w, tallies = newTallies.toList())) + data.postValue(success(null)) + } + catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun answerQuestion(a: String): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val response = storiesRepository.respondToQuestion( + csrfToken!!, + userId, + deviceId, + currentMedia.value!!.pk, + question.value!!.questionId, + a + ) + if (!"ok".equals(response.status)) + throw Exception("Instagram returned status \"" + response.status + "\"") + data.postValue(success(null)) + } + catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun answerSlider(a: Double): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val oldSlider = slider.value!! + val response = storiesRepository.respondToSlider( + csrfToken!!, + userId, + deviceId, + currentMedia.value!!.pk, + oldSlider.sliderId, + a + ) + if (!"ok".equals(response.status)) + throw Exception("Instagram returned status \"" + response.status + "\"") + val newVoteCount = (oldSlider.sliderVoteCount ?: 0) + 1 + val newAverage = if (oldSlider.sliderVoteAverage == null) a + else (oldSlider.sliderVoteAverage * oldSlider.sliderVoteCount!! + a) / newVoteCount + slider.postValue(oldSlider.copy(viewerCanVote = false, + sliderVoteCount = newVoteCount, + viewerVote = a, + sliderVoteAverage = newAverage)) + data.postValue(success(null)) + } + catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun reply(a: String): LiveData>? { + if (messageManager == null) { + messageManager = DirectMessagesManager + } + return messageManager?.replyToStory( + currentStory.value?.user?.pk, + currentStory.value?.id, + currentMedia.value?.id, + a, + viewModelScope + ) + } + + fun shareDm(result: RankedRecipient) { + if (messageManager == null) { + messageManager = DirectMessagesManager + } + val mediaId = currentMedia.value?.id ?: return + val reelId = currentStory.value?.id ?: return + messageManager?.sendMedia(result, mediaId, reelId, BroadcastItemType.STORY, viewModelScope) + } + + fun shareDm(recipients: Set) { + if (messageManager == null) { + messageManager = DirectMessagesManager + } + val mediaId = currentMedia.value?.id ?: return + val reelId = currentStory.value?.id ?: return + messageManager?.sendMedia(recipients, mediaId, reelId, BroadcastItemType.STORY, viewModelScope) + } + + fun paginate(backward: Boolean) { + var index = currentIndex.value!! + index = if (backward) index - 1 else index + 1 + if (index < 0 || index >= currentStory.value!!.items!!.size) skip(backward) + setMedia(index) + } + + fun skip(backward: Boolean) { + pagination.postValue(if (backward) StoryPaginationType.BACKWARD else StoryPaginationType.FORWARD) + } + + fun fetchStory(fetchOptions: StoryViewerOptions?): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val story = storiesRepository.getStories(fetchOptions!!) + setStory(story!!) + data.postValue(success(null)) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun fetchHighlights(id: Long) { + viewModelScope.launch(Dispatchers.IO) { + try { + val result = storiesRepository.fetchHighlights(id) + highlights.postValue(result) + } catch (e: Exception) { + } + } + } + + fun fetchSingleMedia(mediaId: Long): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val storyMedia = storiesRepository.fetch(mediaId) + setSingleMedia(storyMedia!!) + data.postValue(success(null)) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } + + fun markAsSeen(storyMedia: StoryMedia): LiveData> { + val data = MutableLiveData>() + data.postValue(loading(null)) + val oldStory = currentStory.value!! + if (oldStory.seen != null && oldStory.seen >= storyMedia.takenAt) data.postValue(success(null)) + else viewModelScope.launch(Dispatchers.IO) { + try { + storiesRepository.seen( + csrfToken!!, + userId, + deviceId, + storyMedia.id, + storyMedia.takenAt, + System.currentTimeMillis() / 1000 + ) + val newStory = oldStory.copy(seen = storyMedia.takenAt) + data.postValue(success(newStory)) + } catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/TopicClusterViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/TopicClusterViewModel.java new file mode 100644 index 0000000..74ebc7c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/TopicClusterViewModel.java @@ -0,0 +1,19 @@ +package awais.instagrabber.viewmodels; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.List; + +import awais.instagrabber.repositories.responses.discover.TopicCluster; + +public class TopicClusterViewModel extends ViewModel { + private MutableLiveData> list; + + public MutableLiveData> getList() { + if (list == null) { + list = new MutableLiveData<>(); + } + return list; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java new file mode 100644 index 0000000..c9bac54 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java @@ -0,0 +1,367 @@ +package awais.instagrabber.viewmodels; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.google.common.collect.ImmutableList; + +import java.io.IOException; +import java.util.Arrays; +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.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.fragments.UserSearchMode; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.CoroutineUtilsKt; +import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.utils.RankedRecipientsCache; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.DirectMessagesRepository; +import awais.instagrabber.webservices.UserRepository; +import kotlinx.coroutines.Dispatchers; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Response; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class UserSearchViewModel extends ViewModel { + private static final String TAG = UserSearchViewModel.class.getSimpleName(); + public static final String DEBOUNCE_KEY = "search"; + + private String prevQuery; + private String currentQuery; + private Call searchRequest; + private long[] hideUserIds; + private String[] hideThreadIds; + private UserSearchMode searchMode; + private boolean showGroups; + private boolean waitingForCache; + private boolean showCachedResults; + + private final MutableLiveData>> recipients = new MutableLiveData<>(); + private final MutableLiveData showAction = new MutableLiveData<>(false); + private final Debouncer searchDebouncer; + private final Set selectedRecipients = new HashSet<>(); + private final UserRepository userRepository; + private final DirectMessagesRepository directMessagesRepository; + private final RankedRecipientsCache rankedRecipientsCache; + + public UserSearchViewModel() { + final String cookie = settingsHelper.getString(Constants.COOKIE); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + final long viewerId = CookieUtils.getUserIdFromCookie(cookie); + final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); + if (TextUtils.isEmpty(csrfToken) || viewerId <= 0 || TextUtils.isEmpty(deviceUuid)) { + throw new IllegalArgumentException("User is not logged in!"); + } + userRepository = UserRepository.Companion.getInstance(); + directMessagesRepository = DirectMessagesRepository.Companion.getInstance(); + rankedRecipientsCache = RankedRecipientsCache.INSTANCE; + if ((rankedRecipientsCache.isFailed() || rankedRecipientsCache.isExpired()) && !rankedRecipientsCache.isUpdateInitiated()) { + updateRankedRecipientCache(); + } + final Debouncer.Callback searchCallback = new Debouncer.Callback() { + @Override + public void call(final String key) { + if (currentQuery != null && currentQuery.equalsIgnoreCase(prevQuery)) return; + sendSearchRequest(); + prevQuery = currentQuery; + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "onError: ", t); + } + }; + searchDebouncer = new Debouncer<>(searchCallback, 1000); + } + + private void updateRankedRecipientCache() { + rankedRecipientsCache.setUpdateInitiated(true); + directMessagesRepository.rankedRecipients( + null, + null, + null, + CoroutineUtilsKt.getContinuation((response, throwable) -> { + if (throwable != null) { + Log.e(TAG, "updateRankedRecipientCache: ", throwable); + rankedRecipientsCache.setUpdateInitiated(false); + rankedRecipientsCache.setFailed(true); + continueSearchIfRequired(); + return; + } + rankedRecipientsCache.setResponse(response); + rankedRecipientsCache.setUpdateInitiated(false); + continueSearchIfRequired(); + }, Dispatchers.getIO()) + ); + } + + private void continueSearchIfRequired() { + if (!waitingForCache) { + if (showCachedResults) { + recipients.postValue(Resource.success(getCachedRecipients())); + } + return; + } + waitingForCache = false; + sendSearchRequest(); + } + + public LiveData>> getRecipients() { + return recipients; + } + + public void search(@Nullable final String query) { + currentQuery = query; + if (TextUtils.isEmpty(query)) { + cancelSearch(); + if (showCachedResults) { + recipients.postValue(Resource.success(getCachedRecipients())); + } + return; + } + recipients.postValue(Resource.loading(getCachedRecipients())); + searchDebouncer.call(DEBOUNCE_KEY); + } + + private void sendSearchRequest() { + if (!rankedRecipientsCache.isFailed()) { // to avoid infinite loop in case of any network issues + if (rankedRecipientsCache.isUpdateInitiated()) { + // wait for cache first + waitingForCache = true; + return; + } + if (rankedRecipientsCache.isExpired()) { + // update cache first + updateRankedRecipientCache(); + waitingForCache = true; + return; + } + } + switch (searchMode) { + case RAVEN: + case RESHARE: + rankedRecipientSearch(); + break; + case USER_SEARCH: + default: + defaultUserSearch(); + break; + } + } + + private void defaultUserSearch() { + userRepository.search(currentQuery, CoroutineUtilsKt.getContinuation((userSearchResponse, throwable) -> { + if (throwable != null) { + Log.e(TAG, "onFailure: ", throwable); + recipients.postValue(Resource.error(throwable.getMessage(), getCachedRecipients())); + searchRequest = null; + return; + } + if (userSearchResponse == null) { + recipients.postValue(Resource.error(R.string.generic_null_response, getCachedRecipients())); + searchRequest = null; + return; + } + final List list = userSearchResponse + .getUsers() + .stream() + .map(RankedRecipient::of) + .collect(Collectors.toList()); + recipients.postValue(Resource.success(mergeResponseWithCache(list))); + searchRequest = null; + })); + } + + private void rankedRecipientSearch() { + directMessagesRepository.rankedRecipients( + searchMode.getMode(), + showGroups, + currentQuery, + CoroutineUtilsKt.getContinuation((response, throwable) -> { + if (throwable != null) { + Log.e(TAG, "rankedRecipientSearch: ", throwable); + recipients.postValue(Resource.error(throwable.getMessage(), getCachedRecipients())); + return; + } + final List list = response.getRankedRecipients(); + if (list != null) { + recipients.postValue(Resource.success(mergeResponseWithCache(list))); + } + }, Dispatchers.getIO()) + ); + } + + private List mergeResponseWithCache(@NonNull final List list) { + final Iterator iterator = list.stream() + .filter(Objects::nonNull) + .filter(this::filterValidRecipients) + .filter(this::filterOutGroups) + .filter(this::filterIdsToHide) + .iterator(); + return ImmutableList.builder() + .addAll(getCachedRecipients()) // add cached results first + .addAll(iterator) + .build(); + } + + @NonNull + private List getCachedRecipients() { + final List rankedRecipients = rankedRecipientsCache.getRankedRecipients(); + final List list = rankedRecipients != null ? rankedRecipients : Collections.emptyList(); + return list.stream() + .filter(Objects::nonNull) + .filter(this::filterValidRecipients) + .filter(this::filterOutGroups) + .filter(this::filterQuery) + .filter(this::filterIdsToHide) + .collect(Collectors.toList()); + } + + private void handleErrorResponse(final Response response, boolean updateResource) { + final ResponseBody errorBody = response.errorBody(); + if (errorBody == null) { + if (updateResource) { + recipients.postValue(Resource.error(R.string.generic_failed_request, getCachedRecipients())); + } + return; + } + String errorString; + try { + errorString = errorBody.string(); + Log.e(TAG, "handleErrorResponse: " + errorString); + } catch (IOException e) { + Log.e(TAG, "handleErrorResponse: ", e); + errorString = e.getMessage(); + } + if (updateResource) { + recipients.postValue(Resource.error(errorString, getCachedRecipients())); + } + } + + public void cleanup() { + searchDebouncer.terminate(); + } + + public void setSelectedRecipient(final RankedRecipient recipient, final boolean selected) { + if (selected) { + selectedRecipients.add(recipient); + } else { + selectedRecipients.remove(recipient); + } + showAction.postValue(!selectedRecipients.isEmpty()); + } + + public Set getSelectedRecipients() { + return selectedRecipients; + } + + public void clearResults() { + recipients.postValue(Resource.success(Collections.emptyList())); + prevQuery = ""; + } + + public void cancelSearch() { + searchDebouncer.cancel(DEBOUNCE_KEY); + if (searchRequest != null) { + searchRequest.cancel(); + searchRequest = null; + } + } + + public LiveData showAction() { + return showAction; + } + + public void setSearchMode(final UserSearchMode searchMode) { + this.searchMode = searchMode; + } + + public void setShowGroups(final boolean showGroups) { + this.showGroups = showGroups; + } + + public void setHideUserIds(final long[] hideUserIds) { + if (hideUserIds != null) { + final long[] copy = Arrays.copyOf(hideUserIds, hideUserIds.length); + Arrays.sort(copy); + this.hideUserIds = copy; + return; + } + this.hideUserIds = null; + } + + public void setHideThreadIds(final String[] hideThreadIds) { + if (hideThreadIds != null) { + final String[] copy = Arrays.copyOf(hideThreadIds, hideThreadIds.length); + Arrays.sort(copy); + this.hideThreadIds = copy; + return; + } + this.hideThreadIds = null; + } + + private boolean filterOutGroups(@NonNull RankedRecipient recipient) { + // if showGroups is false, remove groups from the list + if (showGroups || recipient.getThread() == null) { + return true; + } + return !recipient.getThread().isGroup(); + } + + private boolean filterValidRecipients(@NonNull RankedRecipient recipient) { + // check if both user and thread are null + return recipient.getUser() != null || recipient.getThread() != null; + } + + private boolean filterIdsToHide(@NonNull RankedRecipient recipient) { + if (hideThreadIds != null && recipient.getThread() != null) { + return Arrays.binarySearch(hideThreadIds, recipient.getThread().getThreadId()) < 0; + } + if (hideUserIds != null) { + long pk = -1; + if (recipient.getUser() != null) { + pk = recipient.getUser().getPk(); + } else if (recipient.getThread() != null && !recipient.getThread().isGroup()) { + final User user = recipient.getThread().getUsers().get(0); + pk = user.getPk(); + } + return Arrays.binarySearch(hideUserIds, pk) < 0; + } + return true; + } + + private boolean filterQuery(@NonNull RankedRecipient recipient) { + if (TextUtils.isEmpty(currentQuery)) { + return true; + } + if (recipient.getThread() != null) { + return recipient.getThread().getThreadTitle().toLowerCase().contains(currentQuery.toLowerCase()); + } + return recipient.getUser().getUsername().toLowerCase().contains(currentQuery.toLowerCase()) + || recipient.getUser().getFullName().toLowerCase().contains(currentQuery.toLowerCase()); + } + + public void showCachedResults() { + this.showCachedResults = true; + if (rankedRecipientsCache.isUpdateInitiated()) return; + recipients.postValue(Resource.success(getCachedRecipients())); + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/factories/DirectSettingsViewModelFactory.java b/app/src/main/java/awais/instagrabber/viewmodels/factories/DirectSettingsViewModelFactory.java new file mode 100644 index 0000000..2d3503b --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/factories/DirectSettingsViewModelFactory.java @@ -0,0 +1,35 @@ +package awais.instagrabber.viewmodels.factories; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.viewmodels.DirectSettingsViewModel; + +public class DirectSettingsViewModelFactory implements ViewModelProvider.Factory { + + private final Application application; + private final String threadId; + private final boolean pending; + private final User currentUser; + + public DirectSettingsViewModelFactory(@NonNull final Application application, + @NonNull final String threadId, + final boolean pending, + @NonNull final User currentUser) { + this.application = application; + this.threadId = threadId; + this.pending = pending; + this.currentUser = currentUser; + } + + @NonNull + @Override + public T create(@NonNull final Class modelClass) { + //noinspection unchecked + return (T) new DirectSettingsViewModel(application, threadId, pending, currentUser); + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/factories/DirectThreadViewModelFactory.java b/app/src/main/java/awais/instagrabber/viewmodels/factories/DirectThreadViewModelFactory.java new file mode 100644 index 0000000..586e1cc --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/factories/DirectThreadViewModelFactory.java @@ -0,0 +1,35 @@ +package awais.instagrabber.viewmodels.factories; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.viewmodels.DirectThreadViewModel; + +public class DirectThreadViewModelFactory implements ViewModelProvider.Factory { + + private final Application application; + private final String threadId; + private final boolean pending; + private final User currentUser; + + public DirectThreadViewModelFactory(@NonNull final Application application, + @NonNull final String threadId, + final boolean pending, + @NonNull final User currentUser) { + this.application = application; + this.threadId = threadId; + this.pending = pending; + this.currentUser = currentUser; + } + + @NonNull + @Override + public T create(@NonNull final Class modelClass) { + //noinspection unchecked + return (T) new DirectThreadViewModel(application, threadId, pending, currentUser); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/CollectionService.java b/app/src/main/java/awais/instagrabber/webservices/CollectionService.java new file mode 100644 index 0000000..b237367 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/CollectionService.java @@ -0,0 +1,120 @@ +package awais.instagrabber.webservices; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.repositories.CollectionRepository; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.utils.Utils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class CollectionService { + private static final String TAG = "ProfileService"; + + private final CollectionRepository repository; + private final String deviceUuid, csrfToken; + private final long userId; + + private static CollectionService instance; + + private CollectionService(final String deviceUuid, + final String csrfToken, + final long userId) { + this.deviceUuid = deviceUuid; + this.csrfToken = csrfToken; + this.userId = userId; + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(CollectionRepository.class); + } + + public String getCsrfToken() { + return csrfToken; + } + + public String getDeviceUuid() { + return deviceUuid; + } + + public long getUserId() { + return userId; + } + + public static CollectionService getInstance(final String deviceUuid, final String csrfToken, final long userId) { + if (instance == null + || !Objects.equals(instance.getCsrfToken(), csrfToken) + || !Objects.equals(instance.getDeviceUuid(), deviceUuid) + || !Objects.equals(instance.getUserId(), userId)) { + instance = new CollectionService(deviceUuid, csrfToken, userId); + } + return instance; + } + + public void addPostsToCollection(final String collectionId, + final List posts, + final ServiceCallback callback) { + final Map form = new HashMap<>(2); + form.put("module_name", "feed_saved_add_to_collection"); + final List ids; + ids = posts.stream() + .map(Media::getPk) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + form.put("added_media_ids", "[" + TextUtils.join(",", ids) + "]"); + changeCollection(collectionId, "edit", form, callback); + } + + public void editCollectionName(final String collectionId, + final String name, + final ServiceCallback callback) { + final Map form = new HashMap<>(1); + form.put("name", name); + changeCollection(collectionId, "edit", form, callback); + } + + public void deleteCollection(final String collectionId, + final ServiceCallback callback) { + changeCollection(collectionId, "delete", null, callback); + } + + public void changeCollection(final String collectionId, + final String action, + final Map options, + final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("_csrftoken", csrfToken); + form.put("_uuid", deviceUuid); + form.put("_uid", userId); + if (options != null) form.putAll(options); + final Map signedForm = Utils.sign(form); + final Call request = repository.changeCollection(collectionId, action, signedForm); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final String collectionsListResponse = response.body(); + if (collectionsListResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(collectionsListResponse); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/CommentService.java b/app/src/main/java/awais/instagrabber/webservices/CommentService.java new file mode 100644 index 0000000..5a8945a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/CommentService.java @@ -0,0 +1,322 @@ +package awais.instagrabber.webservices; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import awais.instagrabber.models.Comment; +import awais.instagrabber.repositories.CommentRepository; +import awais.instagrabber.repositories.responses.ChildCommentsFetchResponse; +import awais.instagrabber.repositories.responses.CommentsFetchResponse; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class CommentService { + private static final String TAG = "CommentService"; + + private final CommentRepository repository; + private final String deviceUuid, csrfToken; + private final long userId; + + private static CommentService instance; + + private CommentService(final String deviceUuid, + final String csrfToken, + final long userId) { + this.deviceUuid = deviceUuid; + this.csrfToken = csrfToken; + this.userId = userId; + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(CommentRepository.class); + } + + public String getCsrfToken() { + return csrfToken; + } + + public String getDeviceUuid() { + return deviceUuid; + } + + public long getUserId() { + return userId; + } + + public static CommentService getInstance(final String deviceUuid, final String csrfToken, final long userId) { + if (instance == null + || !Objects.equals(instance.getCsrfToken(), csrfToken) + || !Objects.equals(instance.getDeviceUuid(), deviceUuid) + || !Objects.equals(instance.getUserId(), userId)) { + instance = new CommentService(deviceUuid, csrfToken, userId); + } + return instance; + } + + public void fetchComments(@NonNull final String mediaId, + final String maxId, + @NonNull final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("can_support_threading", "true"); + if (maxId != null) form.put("max_id", maxId); + final Call request = repository.fetchComments(mediaId, form); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final CommentsFetchResponse cfr = response.body(); + if (cfr == null) callback.onFailure(new Exception("response is empty")); + callback.onSuccess(cfr); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + } + }); + } + + public void fetchChildComments(@NonNull final String mediaId, + @NonNull final String commentId, + final String maxId, + @NonNull final ServiceCallback callback) { + final Map form = new HashMap<>(); + if (maxId != null) form.put("max_id", maxId); + final Call request = repository.fetchChildComments(mediaId, commentId, form); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final ChildCommentsFetchResponse cfr = response.body(); + if (cfr == null) callback.onFailure(new Exception("response is empty")); + callback.onSuccess(cfr); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + } + }); + } + + public void comment(@NonNull final String mediaId, + @NonNull final String comment, + final String replyToCommentId, + @NonNull final ServiceCallback callback) { + final String module = "self_comments_v2"; + final Map form = new HashMap<>(); + // form.put("user_breadcrumb", userBreadcrumb(comment.length())); + form.put("idempotence_token", UUID.randomUUID().toString()); + form.put("_csrftoken", csrfToken); + form.put("_uid", userId); + form.put("_uuid", deviceUuid); + form.put("comment_text", comment); + form.put("containermodule", module); + if (!TextUtils.isEmpty(replyToCommentId)) { + form.put("replied_to_comment_id", replyToCommentId); + } + final Map signedForm = Utils.sign(form); + final Call commentRequest = repository.comment(mediaId, signedForm); + commentRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) { + Log.e(TAG, "Error occurred while creating comment"); + callback.onSuccess(null); + return; + } + try { + final JSONObject jsonObject = new JSONObject(body); + // final String status = jsonObject.optString("status"); + final JSONObject commentJsonObject = jsonObject.optJSONObject("comment"); + Comment comment = null; + if (commentJsonObject != null) { + final JSONObject userJsonObject = commentJsonObject.optJSONObject("user"); + if (userJsonObject != null) { + final Gson gson = new Gson(); + final User user = gson.fromJson(userJsonObject.toString(), User.class); + comment = new Comment( + commentJsonObject.optString("pk"), + commentJsonObject.optString("text"), + commentJsonObject.optLong("created_at"), + 0L, + false, + user, + 0 + ); + } + } + callback.onSuccess(comment); + } catch (Exception e) { + callback.onFailure(e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + } + }); + } + + public void deleteComment(final String mediaId, + final String commentId, + @NonNull final ServiceCallback callback) { + deleteComments(mediaId, Collections.singletonList(commentId), callback); + } + + public void deleteComments(final String mediaId, + final List commentIds, + @NonNull final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("comment_ids_to_delete", android.text.TextUtils.join(",", commentIds)); + form.put("_csrftoken", csrfToken); + form.put("_uid", userId); + form.put("_uuid", deviceUuid); + final Map signedForm = Utils.sign(form); + final Call bulkDeleteRequest = repository.commentsBulkDelete(mediaId, signedForm); + bulkDeleteRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) { + Log.e(TAG, "Error occurred while deleting comments"); + callback.onSuccess(false); + return; + } + try { + final JSONObject jsonObject = new JSONObject(body); + final String status = jsonObject.optString("status"); + callback.onSuccess(status.equals("ok")); + } catch (JSONException e) { + // Log.e(TAG, "Error parsing body", e); + callback.onFailure(e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + // Log.e(TAG, "Error deleting comments", t); + callback.onFailure(t); + } + }); + } + + public void commentLike(@NonNull final String commentId, + @NonNull final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("_csrftoken", csrfToken); + // form.put("_uid", userId); + // form.put("_uuid", deviceUuid); + final Map signedForm = Utils.sign(form); + final Call commentLikeRequest = repository.commentLike(commentId, signedForm); + commentLikeRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) { + Log.e(TAG, "Error occurred while liking comment"); + callback.onSuccess(false); + return; + } + try { + final JSONObject jsonObject = new JSONObject(body); + final String status = jsonObject.optString("status"); + callback.onSuccess(status.equals("ok")); + } catch (JSONException e) { + // Log.e(TAG, "Error parsing body", e); + callback.onFailure(e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "Error liking comment", t); + callback.onFailure(t); + } + }); + } + + public void commentUnlike(final String commentId, + @NonNull final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("_csrftoken", csrfToken); + // form.put("_uid", userId); + // form.put("_uuid", deviceUuid); + final Map signedForm = Utils.sign(form); + final Call commentUnlikeRequest = repository.commentUnlike(commentId, signedForm); + commentUnlikeRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) { + Log.e(TAG, "Error occurred while unliking comment"); + callback.onSuccess(false); + return; + } + try { + final JSONObject jsonObject = new JSONObject(body); + final String status = jsonObject.optString("status"); + callback.onSuccess(status.equals("ok")); + } catch (JSONException e) { + // Log.e(TAG, "Error parsing body", e); + callback.onFailure(e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "Error unliking comment", t); + callback.onFailure(t); + } + }); + } + + public void translate(final String id, + @NonNull final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("id", String.valueOf(id)); + form.put("type", "2"); + final Call request = repository.translate(form); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) { + Log.e(TAG, "Error occurred while translating"); + callback.onSuccess(null); + return; + } + try { + final JSONObject jsonObject = new JSONObject(body); + final String translation = jsonObject.optString("translation"); + callback.onSuccess(translation); + } catch (JSONException e) { + // Log.e(TAG, "Error parsing body", e); + callback.onFailure(e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "Error translating", t); + callback.onFailure(t); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt new file mode 100644 index 0000000..b3a1228 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt @@ -0,0 +1,583 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.repositories.DirectMessagesService +import awais.instagrabber.repositories.requests.directmessages.* +import awais.instagrabber.repositories.responses.directmessages.* +import awais.instagrabber.repositories.responses.giphy.GiphyGif +import awais.instagrabber.utils.TextUtils.extractUrls +import awais.instagrabber.utils.Utils +import org.json.JSONArray +import java.util.* + +open class DirectMessagesRepository(private val service: DirectMessagesService) { + + suspend fun fetchInbox( + cursor: String?, + seqId: Long, + ): DirectInboxResponse { + val queryMap = mutableMapOf( + "visual_message_return_type" to "unseen", + "thread_message_limit" to 10.toString(), + "persistentBadging" to true.toString(), + "limit" to 10.toString(), + ) + if (!cursor.isNullOrBlank()) { + queryMap["cursor"] = cursor + queryMap["direction"] = "older" + } + if (seqId != 0L) { + queryMap["seq_id"] = seqId.toString() + } + return service.fetchInbox(queryMap) + } + + suspend fun fetchThread( + threadId: String, + cursor: String?, + ): DirectThreadFeedResponse { + val queryMap = mutableMapOf( + "visual_message_return_type" to "unseen", + "limit" to 20.toString(), + "direction" to "older", + ) + if (!cursor.isNullOrBlank()) { + queryMap["cursor"] = cursor + } + return service.fetchThread(threadId, queryMap) + } + + suspend fun fetchUnseenCount(): DirectBadgeCount = service.fetchUnseenCount() + + suspend fun broadcastText( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + text: String, + repliedToItemId: String?, + repliedToClientContext: String?, + ): DirectThreadBroadcastResponse { + val urls = extractUrls(text) + if (urls.isNotEmpty()) { + return broadcastLink( + csrfToken, + userId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + text, + urls, + repliedToItemId, + repliedToClientContext + ) + } + val broadcastOptions = TextBroadcastOptions(clientContext, threadIdsOrUserIds, text) + if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { + broadcastOptions.repliedToItemId = repliedToItemId + broadcastOptions.repliedToClientContext = repliedToClientContext + } + return broadcast(csrfToken, userId, deviceUuid, broadcastOptions) + } + + private suspend fun broadcastLink( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + linkText: String, + urls: List, + repliedToItemId: String?, + repliedToClientContext: String?, + ): DirectThreadBroadcastResponse { + val broadcastOptions = LinkBroadcastOptions(clientContext, threadIdsOrUserIds, linkText, urls) + if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { + broadcastOptions.repliedToItemId = repliedToItemId + broadcastOptions.repliedToClientContext = repliedToClientContext + } + return broadcast(csrfToken, userId, deviceUuid, broadcastOptions) + } + + suspend fun broadcastPhoto( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + uploadId: String, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, PhotoBroadcastOptions(clientContext, threadIdsOrUserIds, true, uploadId)) + + suspend fun broadcastVideo( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + uploadId: String, + videoResult: String, + sampled: Boolean, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, VideoBroadcastOptions(clientContext, threadIdsOrUserIds, videoResult, uploadId, sampled)) + + suspend fun broadcastVoice( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + uploadId: String, + waveform: List, + samplingFreq: Int, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, VoiceBroadcastOptions(clientContext, threadIdsOrUserIds, uploadId, waveform, samplingFreq)) + + suspend fun broadcastStoryReply( + csrfToken: String, + userId: Long, + deviceUuid: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + text: String, + mediaId: String, + reelId: String, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, StoryReplyBroadcastOptions(UUID.randomUUID().toString(), threadIdsOrUserIds, text, mediaId, reelId)) + + suspend fun broadcastReaction( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + itemId: String, + emoji: String?, + delete: Boolean, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, ReactionBroadcastOptions(clientContext, threadIdsOrUserIds, itemId, emoji, delete)) + + suspend fun broadcastAnimatedMedia( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + giphyGif: GiphyGif, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, AnimatedMediaBroadcastOptions(clientContext, threadIdsOrUserIds, giphyGif)) + + suspend fun broadcastMediaShare( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + mediaId: String, + childId: String?, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, MediaShareBroadcastOptions(clientContext, threadIdsOrUserIds, mediaId, childId)) + + suspend fun broadcastProfile( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + profileId: String, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, ProfileBroadcastOptions(clientContext, threadIdsOrUserIds, profileId)) + + suspend fun broadcastStory( + csrfToken: String, + userId: Long, + deviceUuid: String, + clientContext: String, + threadIdsOrUserIds: ThreadIdsOrUserIds, + mediaId: String, + reelId: String, + ): DirectThreadBroadcastResponse = + broadcast(csrfToken, userId, deviceUuid, StoryBroadcastOptions(clientContext, threadIdsOrUserIds, mediaId, reelId)) + + private suspend fun broadcast( + csrfToken: String, + userId: Long, + deviceUuid: String, + broadcastOptions: BroadcastOptions, + ): DirectThreadBroadcastResponse { + require(broadcastOptions.clientContext.isNotBlank()) { "Broadcast requires a valid client context value" } + val form = mutableMapOf( + "_csrftoken" to csrfToken, + "_uid" to userId.toString(10), + "__uuid" to deviceUuid, + "client_context" to broadcastOptions.clientContext, + "mutation_token" to broadcastOptions.clientContext, + ) + val threadIds = broadcastOptions.threadIds + val userIds = broadcastOptions.userIds + require(!userIds.isNullOrEmpty() || !threadIds.isNullOrEmpty()) { + "Either pass a list of thread ids or a list of lists of user ids" + } + if (!threadIds.isNullOrEmpty()) { + form["thread_ids"] = JSONArray(threadIds).toString() + } + if (!userIds.isNullOrEmpty()) { + form["recipient_users"] = JSONArray(userIds).toString() + } + val repliedToItemId = broadcastOptions.repliedToItemId + val repliedToClientContext = broadcastOptions.repliedToClientContext + if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { + form["replied_to_item_id"] = repliedToItemId + form["replied_to_client_context"] = repliedToClientContext + } + form.putAll(broadcastOptions.formMap) + form["action"] = "send_item" +// val signedForm = Utils.sign(form) + return service.broadcast(broadcastOptions.itemType.value, form) + } + + suspend fun addUsers( + csrfToken: String, + deviceUuid: String, + threadId: String, + userIds: Collection, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "user_ids" to JSONArray(userIds).toString(), + ) + return service.addUsers(threadId, form) + } + + suspend fun removeUsers( + csrfToken: String, + deviceUuid: String, + threadId: String, + userIds: Collection, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "user_ids" to JSONArray(userIds).toString(), + ) + return service.removeUsers(threadId, form) + } + + suspend fun updateTitle( + csrfToken: String, + deviceUuid: String, + threadId: String, + title: String, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "title" to title, + ) + return service.updateTitle(threadId, form) + } + + suspend fun addAdmins( + csrfToken: String, + deviceUuid: String, + threadId: String, + userIds: Collection, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "user_ids" to JSONArray(userIds).toString(), + ) + return service.addAdmins(threadId, form) + } + + suspend fun removeAdmins( + csrfToken: String, + deviceUuid: String, + threadId: String, + userIds: Collection, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "user_ids" to JSONArray(userIds).toString(), + ) + return service.removeAdmins(threadId, form) + } + + suspend fun deleteItem( + csrfToken: String, + deviceUuid: String, + threadId: String, + itemId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.deleteItem(threadId, itemId, form) + } + + suspend fun rankedRecipients( + mode: String?, + showThreads: Boolean?, + query: String?, + ): RankedRecipientsResponse { + // String correctedMode = mode; + // if (TextUtils.isEmpty(mode) || (!mode.equals("raven") && !mode.equals("reshare"))) { + // correctedMode = "raven"; + // } + val queryMap = mutableMapOf() + if (!mode.isNullOrBlank()) { + queryMap["mode"] = mode + } + if (!query.isNullOrBlank()) { + queryMap["query"] = query + } + if (showThreads != null) { + queryMap["showThreads"] = showThreads.toString() + } + return service.rankedRecipients(queryMap) + } + + suspend fun forward( + toThreadId: String, + itemType: String, + fromThreadId: String, + itemId: String, + ): DirectThreadBroadcastResponse { + val form = mapOf( + "action" to "forward_item", + "thread_id" to toThreadId, + "item_type" to itemType, + "forwarded_from_thread_id" to fromThreadId, + "forwarded_from_thread_item_id" to itemId, + ) + return service.forward(form) + } + + suspend fun createThread( + csrfToken: String, + userId: Long, + deviceUuid: String, + userIds: List, + threadTitle: String?, + ): DirectThread { + val userIdStringList = userIds.map { it.toString() } + val form = mutableMapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "_uid" to userId, + "recipient_users" to JSONArray(userIdStringList).toString(), + ) + if (!threadTitle.isNullOrBlank()) { + form["thread_title"] = threadTitle + } + val signedForm = Utils.sign(form) + return service.createThread(signedForm) + } + + suspend fun mute( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid + ) + return service.mute(threadId, form) + } + + suspend fun unmute( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.unmute(threadId, form) + } + + suspend fun muteMentions( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.muteMentions(threadId, form) + } + + suspend fun unmuteMentions( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.unmuteMentions(threadId, form) + } + + suspend fun participantRequests( + threadId: String, + pageSize: Int, + cursor: String? = null, + ): DirectThreadParticipantRequestsResponse { + return service.participantRequests(threadId, pageSize, cursor) + } + + suspend fun approveParticipantRequests( + csrfToken: String, + deviceUuid: String, + threadId: String, + userIds: List, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "user_ids" to JSONArray(userIds).toString(), + // "share_join_chat_story" to String.valueOf(true) + ) + return service.approveParticipantRequests(threadId, form) + } + + suspend fun declineParticipantRequests( + csrfToken: String, + deviceUuid: String, + threadId: String, + userIds: List, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "user_ids" to JSONArray(userIds).toString(), + ) + return service.declineParticipantRequests(threadId, form) + } + + suspend fun approvalRequired( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.approvalRequired(threadId, form) + } + + suspend fun approvalNotRequired( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.approvalNotRequired(threadId, form) + } + + suspend fun leave( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.leave(threadId, form) + } + + suspend fun end( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): DirectThreadDetailsChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.end(threadId, form) + } + + suspend fun fetchPendingInbox(cursor: String?, seqId: Long): DirectInboxResponse { + val queryMap = mutableMapOf( + "visual_message_return_type" to "unseen", + "thread_message_limit" to 20.toString(), + "persistentBadging" to true.toString(), + "limit" to 10.toString(), + ) + if (!cursor.isNullOrBlank()) { + queryMap["cursor"] = cursor + queryMap["direction"] = "older" + } + if (seqId != 0L) { + queryMap["seq_id"] = seqId.toString() + } + return service.fetchPendingInbox(queryMap) + } + + suspend fun approveRequest( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.approveRequest(threadId, form) + } + + suspend fun declineRequest( + csrfToken: String, + deviceUuid: String, + threadId: String, + ): String { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + ) + return service.declineRequest(threadId, form) + } + + suspend fun markAsSeen( + csrfToken: String, + deviceUuid: String, + threadId: String, + directItem: DirectItem, + ): DirectItemSeenResponse? { + val itemId = directItem.itemId ?: return null + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "use_unified_inbox" to "true", + "action" to "mark_seen", + "thread_id" to threadId, + "item_id" to itemId, + ) + return service.markItemSeen(threadId, itemId, form) + } + + companion object { + @Volatile + private var INSTANCE: DirectMessagesRepository? = null + + fun getInstance(): DirectMessagesRepository { + return INSTANCE ?: synchronized(this) { + val service: DirectMessagesService = RetrofitFactory.retrofit.create(DirectMessagesService::class.java) + DirectMessagesRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/DiscoverService.java b/app/src/main/java/awais/instagrabber/webservices/DiscoverService.java new file mode 100644 index 0000000..0c6ff76 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/DiscoverService.java @@ -0,0 +1,372 @@ +package awais.instagrabber.webservices; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableMap; + +import java.util.Objects; + +import awais.instagrabber.repositories.DiscoverRepository; +import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; +import awais.instagrabber.utils.TextUtils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class DiscoverService { + + private static final String TAG = "DiscoverService"; + + private final DiscoverRepository repository; + + private static DiscoverService instance; + + private DiscoverService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(DiscoverRepository.class); + } + + public static DiscoverService getInstance() { + if (instance == null) { + instance = new DiscoverService(); + } + return instance; + } + + public void topicalExplore(@NonNull final TopicalExploreRequest request, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder() + .put("module", "explore_popular"); + if (!TextUtils.isEmpty(request.getModule())) { + builder.put("module", request.getModule()); + } + if (!TextUtils.isEmpty(request.getClusterId())) { + builder.put("cluster_id", request.getClusterId()); + } + if (!TextUtils.isEmpty(request.getMaxId())) { + builder.put("max_id", request.getMaxId()); + } + final Call req = repository.topicalExplore(builder.build()); + req.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, + @NonNull final Response response) { + if (callback == null) return; + final TopicalExploreFeedResponse feedResponse = response.body(); + if (feedResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(feedResponse); + // try { + // } catch (JSONException e) { + // callback.onFailure(e); + // // Log.e(TAG, "Error parsing topicalExplore response", e); + // } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + } + }); + } + + // private TopicalExploreResponse parseTopicalExploreResponse(@NonNull final String body) throws JSONException { + // final JSONObject root = new JSONObject(body); + // final boolean moreAvailable = root.optBoolean("more_available"); + // final int nextMaxId = root.optInt("next_max_id", -1); + // final int numResults = root.optInt("num_results"); + // final String status = root.optString("status"); + // final JSONArray clustersJson = root.optJSONArray("clusters"); + // final List clusters = parseClusters(clustersJson); + // final JSONArray itemsJson = root.optJSONArray("items"); + // final List items = parseItems(itemsJson); + // return new TopicalExploreResponse( + // moreAvailable, + // nextMaxId, + // numResults, + // status, + // clusters, + // items + // ); + // } + + // private List parseClusters(final JSONArray clustersJson) throws JSONException { + // if (clustersJson == null) { + // return Collections.emptyList(); + // } + // final List clusters = new ArrayList<>(); + // for (int i = 0; i < clustersJson.length(); i++) { + // final JSONObject clusterJson = clustersJson.getJSONObject(i); + // final String id = clusterJson.optString("id"); + // final String title = clusterJson.optString("title"); + // if (TextUtils.isEmpty(id) || TextUtils.isEmpty(title)) { + // continue; + // } + // final String type = clusterJson.optString("type"); + // final boolean canMute = clusterJson.optBoolean("can_mute"); + // final boolean getMuted = clusterJson.optBoolean("is_muted"); + // final JSONObject coverMediaJson = clusterJson.optJSONObject("cover_media"); + // final int rankedPosition = clusterJson.optInt("ranked_position"); + // final FeedModel feedModel = parseClusterCover(coverMediaJson); + // final TopicCluster topicCluster = new TopicCluster( + // id, + // title, + // type, + // canMute, + // getMuted, + // rankedPosition, + // feedModel + // ); + // clusters.add(topicCluster); + // } + // return clusters; + // } + + // private FeedModel parseClusterCover(final JSONObject coverMediaJson) throws JSONException { + // if (coverMediaJson == null) { + // return null; + // } + // ProfileModel profileModel = null; + // if (coverMediaJson.has("user")) { + // final JSONObject user = coverMediaJson.getJSONObject("user"); + // profileModel = new ProfileModel( + // user.optBoolean("is_private"), + // false, + // user.optBoolean("is_verified"), + // user.getString("pk"), + // user.getString(Constants.EXTRAS_USERNAME), + // user.optString("full_name"), + // null, + // null, + // user.getString("profile_pic_url"), + // null, + // 0, + // 0, + // 0, + // false, + // false, + // false, + // false, + // false); + // } + // final String resourceUrl = ResponseBodyUtils.getHighQualityImage(coverMediaJson); + // final String thumbnailUrl = ResponseBodyUtils.getLowQualityImage(coverMediaJson); + // final int width = coverMediaJson.optInt("original_width"); + // final int height = coverMediaJson.optInt("original_height"); + // return new FeedModel.Builder() + // .setProfileModel(profileModel) + // .setItemType(MediaItemType.MEDIA_TYPE_IMAGE) + // .setViewCount(0) + // .setPostId(coverMediaJson.getString(Constants.EXTRAS_ID)) + // .setDisplayUrl(resourceUrl) + // .setThumbnailUrl(thumbnailUrl) + // .setShortCode(coverMediaJson.getString("code")) + // .setPostCaption(null) + // .setCommentsCount(0) + // .setTimestamp(coverMediaJson.optLong("taken_at", -1)) + // .setLiked(false) + // .setBookmarked(false) + // .setLikesCount(0) + // .setLocationName(null) + // .setLocationId(null) + // .setImageHeight(height) + // .setImageWidth(width) + // .build(); + // } + + // private List parseItems(final JSONArray items) throws JSONException { + // if (items == null) { + // return Collections.emptyList(); + // } + // final List feedModels = new ArrayList<>(); + // for (int i = 0; i < items.length(); i++) { + // final JSONObject itemJson = items.optJSONObject(i); + // if (itemJson == null) { + // continue; + // } + // final JSONObject mediaJson = itemJson.optJSONObject("media"); + // final FeedModel feedModel = ResponseBodyUtils.parseItem(mediaJson); + // if (feedModel != null) { + // feedModels.add(feedModel); + // } + // } + // return feedModels; + // } + + public static class TopicalExploreRequest { + + private String module; + private String clusterId; + private String maxId; + + public TopicalExploreRequest() {} + + public TopicalExploreRequest(final String module, final String clusterId, final String maxId) { + this.module = module; + this.clusterId = clusterId; + this.maxId = maxId; + } + + public String getModule() { + return module; + } + + public TopicalExploreRequest setModule(final String module) { + this.module = module; + return this; + } + + public String getClusterId() { + return clusterId; + } + + public void setClusterId(final String clusterId) { + this.clusterId = clusterId; + } + + public String getMaxId() { + return maxId; + } + + public void setMaxId(final String maxId) { + this.maxId = maxId; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final TopicalExploreRequest that = (TopicalExploreRequest) o; + return maxId.equals(that.maxId) && + Objects.equals(module, that.module) && + Objects.equals(clusterId, that.clusterId); + } + + @Override + public int hashCode() { + return Objects.hash(module, clusterId, maxId); + } + + @NonNull + @Override + public String toString() { + return "TopicalExploreRequest{" + + "module='" + module + '\'' + + ", clusterId='" + clusterId + '\'' + + ", maxId=" + maxId + + '}'; + } + } + + // public static class TopicalExploreResponse { + // + // private boolean moreAvailable; + // private int nextMaxId; + // private int numResults; + // private String status; + // private List clusters; + // private List items; + // + // public TopicalExploreResponse() {} + // + // public TopicalExploreResponse(final boolean moreAvailable, + // final int nextMaxId, + // final int numResults, + // final String status, + // final List clusters, final List items) { + // this.moreAvailable = moreAvailable; + // this.nextMaxId = nextMaxId; + // this.numResults = numResults; + // this.status = status; + // this.clusters = clusters; + // this.items = items; + // } + // + // public boolean isMoreAvailable() { + // return moreAvailable; + // } + // + // public TopicalExploreResponse setMoreAvailable(final boolean moreAvailable) { + // this.moreAvailable = moreAvailable; + // return this; + // } + // + // public int getNextMaxId() { + // return nextMaxId; + // } + // + // public TopicalExploreResponse setNextMaxId(final int nextMaxId) { + // this.nextMaxId = nextMaxId; + // return this; + // } + // + // public int getNumResults() { + // return numResults; + // } + // + // public TopicalExploreResponse setNumResults(final int numResults) { + // this.numResults = numResults; + // return this; + // } + // + // public String getStatus() { + // return status; + // } + // + // public TopicalExploreResponse setStatus(final String status) { + // this.status = status; + // return this; + // } + // + // public List getClusters() { + // return clusters; + // } + // + // public TopicalExploreResponse setClusters(final List clusters) { + // this.clusters = clusters; + // return this; + // } + // + // public List getItems() { + // return items; + // } + // + // public TopicalExploreResponse setItems(final List items) { + // this.items = items; + // return this; + // } + // + // @Override + // public boolean equals(final Object o) { + // if (this == o) return true; + // if (o == null || getClass() != o.getClass()) return false; + // final TopicalExploreResponse that = (TopicalExploreResponse) o; + // return moreAvailable == that.moreAvailable && + // nextMaxId == that.nextMaxId && + // numResults == that.numResults && + // Objects.equals(status, that.status) && + // Objects.equals(clusters, that.clusters) && + // Objects.equals(items, that.items); + // } + // + // @Override + // public int hashCode() { + // return Objects.hash(moreAvailable, nextMaxId, numResults, status, clusters, items); + // } + // + // @Override + // public String toString() { + // return "TopicalExploreResponse{" + + // "moreAvailable=" + moreAvailable + + // ", nextMaxId=" + nextMaxId + + // ", numResults=" + numResults + + // ", status='" + status + '\'' + + // ", clusters=" + clusters + + // ", items=" + items + + // '}'; + // } + // } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/FeedService.java b/app/src/main/java/awais/instagrabber/webservices/FeedService.java new file mode 100644 index 0000000..5e6dbf4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/FeedService.java @@ -0,0 +1,133 @@ +package awais.instagrabber.webservices; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import awais.instagrabber.repositories.FeedRepository; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.repositories.responses.feed.EndOfFeedDemarcator; +import awais.instagrabber.repositories.responses.feed.EndOfFeedGroup; +import awais.instagrabber.repositories.responses.feed.EndOfFeedGroupSet; +import awais.instagrabber.repositories.responses.feed.FeedFetchResponse; +import awais.instagrabber.utils.TextUtils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class FeedService { + private static final String TAG = "FeedService"; + + private final FeedRepository repository; + + private static FeedService instance; + + private FeedService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(FeedRepository.class); + } + + public static FeedService getInstance() { + if (instance == null) { + instance = new FeedService(); + } + return instance; + } + + public void fetch(final String csrfToken, + final String deviceUuid, + final String cursor, + final ServiceCallback callback) { + final Map form = new HashMap<>(); + form.put("_uuid", deviceUuid); + form.put("_csrftoken", csrfToken); + form.put("phone_id", UUID.randomUUID().toString()); + form.put("device_id", UUID.randomUUID().toString()); + form.put("client_session_id", UUID.randomUUID().toString()); + form.put("is_prefetch", "0"); + if (!TextUtils.isEmpty(cursor)) { + form.put("max_id", cursor); + form.put("reason", "pagination"); + } else { + form.put("is_pull_to_refresh", "1"); + form.put("reason", "pull_to_refresh"); + } + final Call request = repository.fetch(form); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + try { + // Log.d(TAG, "onResponse: body: " + response.body()); + final PostsFetchResponse postsFetchResponse = parseResponse(response); + if (callback != null) { + callback.onSuccess(postsFetchResponse); + } + } catch (Exception e) { + Log.e(TAG, "onResponse", e); + if (callback != null) { + callback.onFailure(e); + } + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + + } + + @NonNull + private PostsFetchResponse parseResponse(@NonNull final Response response) { + final FeedFetchResponse feedFetchResponse = response.body(); + if (feedFetchResponse == null) { + Log.e(TAG, "parseResponse: feed response body is empty with status code: " + response.code()); + return new PostsFetchResponse(Collections.emptyList(), false, null); + } + return parseResponseBody(feedFetchResponse); + } + + @NonNull + private PostsFetchResponse parseResponseBody(@NonNull final FeedFetchResponse feedFetchResponse) { + final boolean moreAvailable = feedFetchResponse.isMoreAvailable(); + String nextMaxId = feedFetchResponse.getNextMaxId(); + final boolean needNewMaxId = nextMaxId.equals("feed_recs_head_load"); + final List allPosts = new ArrayList<>(); + final List items = feedFetchResponse.getItems(); + for (final Media media : items) { + if (needNewMaxId && media.getEndOfFeedDemarcator() != null) { + final EndOfFeedDemarcator endOfFeedDemarcator = media.getEndOfFeedDemarcator(); + final EndOfFeedGroupSet groupSet = endOfFeedDemarcator.getGroupSet(); + if (groupSet == null) continue; + final List groups = groupSet.getGroups(); + if (groups == null) continue; + for (final EndOfFeedGroup group : groups) { + final String id = group.getId(); + if (id == null || !id.equals("past_posts")) continue; + nextMaxId = group.getNextMaxId(); + final List feedItems = group.getFeedItems(); + for (final Media feedItem : feedItems) { + if (feedItem == null || feedItem.isInjected() || feedItem.getType() == null) continue; + allPosts.add(feedItem); + } + } + continue; + } + if (media == null || media.isInjected() || media.getType() == null) continue; + allPosts.add(media); + } + return new PostsFetchResponse(allPosts, moreAvailable, nextMaxId); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/FriendshipRepository.kt b/app/src/main/java/awais/instagrabber/webservices/FriendshipRepository.kt new file mode 100644 index 0000000..7736399 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/FriendshipRepository.kt @@ -0,0 +1,131 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.repositories.FriendshipService +import awais.instagrabber.repositories.responses.FriendshipChangeResponse +import awais.instagrabber.repositories.responses.FriendshipListFetchResponse +import awais.instagrabber.repositories.responses.FriendshipRestrictResponse +import awais.instagrabber.utils.Utils +import awais.instagrabber.webservices.RetrofitFactory.retrofit + +class FriendshipRepository(private val service: FriendshipService) { + + suspend fun follow( + csrfToken: String, + userId: Long, + deviceUuid: String, + targetUserId: Long, + ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "create", targetUserId) + + suspend fun unfollow( + csrfToken: String, + userId: Long, + deviceUuid: String, + targetUserId: Long, + ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "destroy", targetUserId) + + suspend fun changeBlock( + csrfToken: String, + userId: Long, + deviceUuid: String, + unblock: Boolean, + targetUserId: Long, + ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, if (unblock) "unblock" else "block", targetUserId) + + suspend fun toggleRestrict( + csrfToken: String, + deviceUuid: String, + targetUserId: Long, + restrict: Boolean, + ): FriendshipRestrictResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uuid" to deviceUuid, + "target_user_id" to targetUserId.toString(), + ) + val action = if (restrict) "restrict" else "unrestrict" + return service.toggleRestrict(action, form) + } + + suspend fun approve( + csrfToken: String, + userId: Long, + deviceUuid: String, + targetUserId: Long, + ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "approve", targetUserId) + + suspend fun ignore( + csrfToken: String, + userId: Long, + deviceUuid: String, + targetUserId: Long, + ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "ignore", targetUserId) + + suspend fun removeFollower( + csrfToken: String, + userId: Long, + deviceUuid: String, + targetUserId: Long, + ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "remove_follower", targetUserId) + + private suspend fun change( + csrfToken: String, + userId: Long, + deviceUuid: String, + action: String, + targetUserId: Long, + ): FriendshipChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uid" to userId, + "_uuid" to deviceUuid, + "radio_type" to "wifi-none", + "user_id" to targetUserId, + ) + val signedForm = Utils.sign(form) + return service.change(action, targetUserId, signedForm) + } + + suspend fun changeMute( + csrfToken: String, + userId: Long, + deviceUuid: String, + unmute: Boolean, + targetUserId: Long, + story: Boolean, // true for story, false for posts + ): FriendshipChangeResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uid" to userId.toString(), + "_uuid" to deviceUuid, + (if (story) "target_reel_author_id" else "target_posts_author_id") to targetUserId.toString(), + ) + return service.changeMute( + if (unmute) "unmute_posts_or_story_from_follow" else "mute_posts_or_story_from_follow", + form + ) + } + + suspend fun getList( + follower: Boolean, + targetUserId: Long, + maxId: String?, + query: String? + ): FriendshipListFetchResponse { + val queryMap: MutableMap = mutableMapOf() + if (!maxId.isNullOrEmpty()) queryMap.set("max_id", maxId) + if (!query.isNullOrEmpty()) queryMap.set("query", query) + return service.getList(targetUserId, if (follower) "followers" else "following", queryMap.toMap()) + } + + companion object { + @Volatile + private var INSTANCE: FriendshipRepository? = null + + fun getInstance(): FriendshipRepository { + return INSTANCE ?: synchronized(this) { + val service: FriendshipService = retrofit.create(FriendshipService::class.java) + FriendshipRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/GifService.java b/app/src/main/java/awais/instagrabber/webservices/GifService.java new file mode 100644 index 0000000..0db0f95 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/GifService.java @@ -0,0 +1,31 @@ +package awais.instagrabber.webservices; + +import awais.instagrabber.repositories.GifRepository; +import awais.instagrabber.repositories.responses.giphy.GiphyGifResponse; +import retrofit2.Call; + +public class GifService { + + private final GifRepository repository; + + private static GifService instance; + + private GifService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(GifRepository.class); + } + + public static GifService getInstance() { + if (instance == null) { + instance = new GifService(); + } + return instance; + } + + public Call searchGiphyGifs(final String query, + final boolean includeGifs) { + final String mediaTypes = includeGifs ? "[\"giphy_gifs\",\"giphy\"]" : "[\"giphy\"]"; + return repository.searchGiphyGifs("direct", query, mediaTypes); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt b/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt new file mode 100644 index 0000000..e54b6ec --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt @@ -0,0 +1,307 @@ +package awais.instagrabber.webservices + +import android.util.Log +import awais.instagrabber.models.enums.FollowingType +import awais.instagrabber.repositories.GraphQLService +import awais.instagrabber.repositories.responses.* +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.ResponseBodyUtils +import awais.instagrabber.utils.extensions.TAG +import org.json.JSONException +import org.json.JSONObject +import java.util.* + + +open class GraphQLRepository(private val service: GraphQLService) { + + // TODO convert string response to a response class + private suspend fun fetch( + queryHash: String, + variables: String, + arg1: String, + arg2: String, + backup: User?, + ): PostsFetchResponse { + val queryMap = mapOf( + "query_hash" to queryHash, + "variables" to variables, + ) + val response = service.fetch(queryMap) + return parsePostResponse(response, arg1, arg2, backup) + } + + suspend fun fetchLocationPosts( + locationId: Long, + maxId: String?, + ): PostsFetchResponse = fetch( + "36bd0f2bf5911908de389b8ceaa3be6d", + "{\"id\":\"" + locationId + "\"," + "\"first\":25," + "\"after\":\"" + (maxId ?: "") + "\"}", + Constants.EXTRAS_LOCATION, + "edge_location_to_media", + null + ) + + suspend fun fetchHashtagPosts( + tag: String, + maxId: String?, + ): PostsFetchResponse = fetch( + "9b498c08113f1e09617a1703c22b2f32", + "{\"tag_name\":\"" + tag + "\"," + "\"first\":25," + "\"after\":\"" + (maxId ?: "") + "\"}", + Constants.EXTRAS_HASHTAG, + "edge_hashtag_to_media", + null, + ) + + suspend fun fetchProfilePosts( + profileId: Long, + postsPerPage: Int, + maxId: String?, + backup: User?, + ): PostsFetchResponse = fetch( + "02e14f6a7812a876f7d133c9555b1151", + "{\"id\":\"" + profileId + "\"," + "\"first\":" + postsPerPage + "," + "\"after\":\"" + (maxId ?: "") + "\"}", + Constants.EXTRAS_USER, + "edge_owner_to_timeline_media", + backup, + ) + + suspend fun fetchTaggedPosts( + profileId: Long, + postsPerPage: Int, + maxId: String?, + ): PostsFetchResponse = fetch( + "31fe64d9463cbbe58319dced405c6206", + "{\"id\":\"" + profileId + "\"," + "\"first\":" + postsPerPage + "," + "\"after\":\"" + (maxId ?: "") + "\"}", + Constants.EXTRAS_USER, + "edge_user_to_photos_of_you", + null, + ) + + @Throws(JSONException::class) + private fun parsePostResponse( + response: String, + arg1: String, + arg2: String, + backup: User?, + ): PostsFetchResponse { + if (response.isBlank()) { + Log.e(TAG, "parseResponse: feed response body is empty") + return PostsFetchResponse(emptyList(), false, null) + } + return parseResponseBody(response, arg1, arg2, backup) + } + + @Throws(JSONException::class) + private fun parseResponseBody( + body: String, + arg1: String, + arg2: String, + backup: User?, + ): PostsFetchResponse { + val items: MutableList = ArrayList() + val timelineFeed = JSONObject(body) + .getJSONObject("data") + .getJSONObject(arg1) + .getJSONObject(arg2) + val endCursor: String? + val hasNextPage: Boolean + val pageInfo = timelineFeed.getJSONObject("page_info") + if (pageInfo.has("has_next_page")) { + hasNextPage = pageInfo.getBoolean("has_next_page") + endCursor = if (hasNextPage) pageInfo.getString("end_cursor") else null + } else { + hasNextPage = false + endCursor = null + } + val feedItems = timelineFeed.getJSONArray("edges") + for (i in 0 until feedItems.length()) { + val itemJson = feedItems.optJSONObject(i) ?: continue + val media = ResponseBodyUtils.parseGraphQLItem(itemJson, backup) + if (media != null) { + items.add(media) + } + } + return PostsFetchResponse(items, hasNextPage, endCursor) + } + + // TODO convert string response to a response class + suspend fun fetchCommentLikers( + commentId: String, + endCursor: String?, + ): GraphQLUserListFetchResponse { + val queryMap = mapOf( + "query_hash" to "5f0b1f6281e72053cbc07909c8d154ae", + "variables" to "{\"comment_id\":\"" + commentId + "\"," + "\"first\":30," + "\"after\":\"" + (endCursor ?: "") + "\"}" + ) + val response = service.fetch(queryMap) + val body = JSONObject(response) + val status = body.getString("status") + val data = body.getJSONObject("data").getJSONObject("comment").getJSONObject("edge_liked_by") + val pageInfo = data.getJSONObject("page_info") + val newEndCursor = if (pageInfo.getBoolean("has_next_page")) pageInfo.getString("end_cursor") else null + val users = data.getJSONArray("edges") + val usersLen = users.length() + val userModels: MutableList = ArrayList() + for (j in 0 until usersLen) { + val userObject = users.getJSONObject(j).getJSONObject("node") + userModels.add( + User( + userObject.getLong("id"), + userObject.getString("username"), + userObject.optString("full_name"), + userObject.optBoolean("is_private"), + userObject.getString("profile_pic_url"), + userObject.optBoolean("is_verified") + ) + ) + } + return GraphQLUserListFetchResponse(newEndCursor, status, userModels) + } + + suspend fun fetchComments( + shortCodeOrCommentId: String?, + root: Boolean, + cursor: String?, + ): String { + val variables = mapOf( + (if (root) "shortcode" else "comment_id") to shortCodeOrCommentId, + "first" to 50, + "after" to (cursor ?: "") + ) + val queryMap = mapOf( + "query_hash" to if (root) "bc3296d1ce80a24b1b6e40b1e72903f5" else "51fdd02b67508306ad4484ff574a0b62", + "variables" to JSONObject(variables).toString() + ) + return service.fetch(queryMap) + } + + // TODO convert string response to a response class + open suspend fun fetchUser( + username: String, + ): User? { + val response = service.getUser(username) + try { + val body = JSONObject( + response + .split("").get(0) + .trim().replace(Regex("\\};$"), "}") + ) + val userJson = body + .getJSONObject("entry_data") + .getJSONArray("ProfilePage") + .getJSONObject(0) + .getJSONObject("graphql") + .getJSONObject(Constants.EXTRAS_USER) + val isPrivate = userJson.getBoolean("is_private") + val id = userJson.optLong(Constants.EXTRAS_ID, 0) + val timelineMedia = userJson.getJSONObject("edge_owner_to_timeline_media") + // if (timelineMedia.has("edges")) { + // final JSONArray edges = timelineMedia.getJSONArray("edges"); + // } + var url: String? = userJson.optString("external_url") + if (url.isNullOrBlank()) url = null + return User( + id, + username, + userJson.getString("full_name"), + isPrivate, + userJson.getString("profile_pic_url_hd"), + userJson.getBoolean("is_verified"), + friendshipStatus = FriendshipStatus( + userJson.optBoolean("followed_by_viewer"), + userJson.optBoolean("follows_viewer"), + userJson.optBoolean("blocked_by_viewer"), + false, + isPrivate, + userJson.optBoolean("has_requested_viewer"), + userJson.optBoolean("requested_by_viewer"), + false, + userJson.optBoolean("restricted_by_viewer"), + false + ), + mediaCount = timelineMedia.getLong("count"), + followerCount = userJson.getJSONObject("edge_followed_by").getLong("count"), + followingCount = userJson.getJSONObject("edge_follow").getLong("count"), + biography = userJson.getString("biography"), + externalUrl = url, + ) + } + catch (e: Exception) { + Log.e(TAG, "fetchUser failed", e) + return null + } + } + + // TODO convert string response to a response class + suspend fun fetchPost( + shortcode: String, + ): Media { + val response = service.getPost(shortcode) + val body = JSONObject(response) + val media = body.getJSONObject("graphql").getJSONObject("shortcode_media") + return ResponseBodyUtils.parseGraphQLItem(media, null) + } + + // TODO convert string response to a response class + suspend fun fetchTag( + tag: String, + ): Hashtag { + val response = service.getTag(tag) + val body = JSONObject(response + .split("").get(0) + .trim().replace(Regex("\\};$"), "}")) + .getJSONObject("entry_data") + .getJSONArray("TagPage") + .getJSONObject(0) + .getJSONObject("graphql") + .getJSONObject(Constants.EXTRAS_HASHTAG) + val timelineMedia = body.getJSONObject("edge_hashtag_to_media") + return Hashtag( + body.getString(Constants.EXTRAS_ID), + body.getString("name"), + timelineMedia.getLong("count"), + if (body.optBoolean("is_following")) FollowingType.FOLLOWING else FollowingType.NOT_FOLLOWING, + null + ) + } + + // TODO convert string response to a response class + suspend fun fetchLocation( + locationId: Long, + ): Location { + val response = service.getLocation(locationId) + val body = JSONObject(response + .split("").get(1) + .trim().replace(Regex("};$"), "}")) + .getJSONObject("entry_data") + .getJSONArray("LocationsPage") + .getJSONObject(0) + .getJSONObject("graphql") + .getJSONObject(Constants.EXTRAS_LOCATION) + // val timelineMedia = body.getJSONObject("edge_location_to_media") + val address = JSONObject(body.getString("address_json")) + return Location( + body.getLong(Constants.EXTRAS_ID), + body.getString("slug"), + body.getString("name"), + address.optString("street_address"), + address.optString("city_name"), + body.optDouble("lng", 0.0), + body.optDouble("lat", 0.0) + ) + } + + companion object { + @Volatile + private var INSTANCE: GraphQLRepository? = null + + fun getInstance(): GraphQLRepository { + return INSTANCE ?: synchronized(this) { + val service: GraphQLService = RetrofitFactory.retrofitWeb.create(GraphQLService::class.java) + GraphQLRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/LocationService.java b/app/src/main/java/awais/instagrabber/webservices/LocationService.java new file mode 100644 index 0000000..3ed70b9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/LocationService.java @@ -0,0 +1,92 @@ +package awais.instagrabber.webservices; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableMap; + +import awais.instagrabber.repositories.LocationRepository; +import awais.instagrabber.repositories.responses.Location; +import awais.instagrabber.repositories.responses.LocationFeedResponse; +import awais.instagrabber.repositories.responses.Place; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.utils.TextUtils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class LocationService { + private static final String TAG = "LocationService"; + + private final LocationRepository repository; + + private static LocationService instance; + + private LocationService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(LocationRepository.class); + } + + public static LocationService getInstance() { + if (instance == null) { + instance = new LocationService(); + } + return instance; + } + + public void fetchPosts(final long locationId, + final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + final Call request = repository.fetchPosts(locationId, builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final LocationFeedResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + final PostsFetchResponse postsFetchResponse = new PostsFetchResponse( + body.getItems(), + body.getMoreAvailable(), + body.getNextMaxId() + ); + callback.onSuccess(postsFetchResponse); + + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void fetch(@NonNull final long locationId, + final ServiceCallback callback) { + final Call request = repository.fetch(locationId); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) { + return; + } + callback.onSuccess(response.body() == null ? null : response.body().getLocation()); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt b/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt new file mode 100644 index 0000000..c2762b9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt @@ -0,0 +1,197 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.models.enums.MediaItemType +import awais.instagrabber.repositories.MediaService +import awais.instagrabber.repositories.requests.Clip +import awais.instagrabber.repositories.requests.UploadFinishOptions +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.utils.DateUtils +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.retryContextString +import awais.instagrabber.webservices.RetrofitFactory.retrofit +import org.json.JSONObject + +class MediaRepository(private val service: MediaService) { + + suspend fun fetch( + mediaId: Long, + ): Media? { + val response = service.fetch(mediaId) + return if (response.items.isNullOrEmpty()) { + null + } else response.items[0] + } + + suspend fun like( + csrfToken: String, + userId: Long, + deviceUuid: String, + mediaId: String, + ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "like", null) + + suspend fun unlike( + csrfToken: String, + userId: Long, + deviceUuid: String, + mediaId: String, + ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "unlike", null) + + suspend fun save( + csrfToken: String, + userId: Long, + deviceUuid: String, + mediaId: String, collection: String?, + ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "save", collection) + + suspend fun unsave( + csrfToken: String, + userId: Long, + deviceUuid: String, + mediaId: String, + ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "unsave", null) + + private suspend fun action( + csrfToken: String, + userId: Long, + deviceUuid: String, + mediaId: String, + action: String, + collection: String?, + ): Boolean { + val form: MutableMap = mutableMapOf( + "media_id" to mediaId, + "_csrftoken" to csrfToken, + "_uid" to userId, + "_uuid" to deviceUuid, + ) + // form.put("radio_type", "wifi-none"); + if (action == "save" && !collection.isNullOrBlank()) { + form["added_collection_ids"] = "[$collection]" + } + // there also exists "removed_collection_ids" which can be used with "save" and "unsave" + val signedForm = Utils.sign(form) + val response = service.action(action, mediaId, signedForm) + val jsonObject = JSONObject(response) + val status = jsonObject.optString("status") + return status == "ok" + } + + suspend fun editCaption( + csrfToken: String, + userId: Long, + deviceUuid: String, + postId: String, + newCaption: String, + ): Boolean { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uid" to userId, + "_uuid" to deviceUuid, + "igtv_feed_preview" to "false", + "media_id" to postId, + "caption_text" to newCaption, + ) + val signedForm = Utils.sign(form) + val response = service.editCaption(postId, signedForm) + val jsonObject = JSONObject(response) + val status = jsonObject.optString("status") + return status == "ok" + } + + suspend fun fetchLikes( + mediaId: String, + isComment: Boolean, + ): List { + val response = service.fetchLikes(mediaId, if (isComment) "comment_likers" else "likers") + return response.users + } + + suspend fun translate( + id: String, + type: String, // 1 caption 2 comment 3 bio + ): String? { + val form = mapOf( + "id" to id, + "type" to type, + ) + val response = service.translate(form) + val jsonObject = JSONObject(response) + if (!jsonObject.has("translation") || jsonObject.isNull("translation")) { + return null + } + return jsonObject.getString("translation") + } + + suspend fun uploadFinish( + csrfToken: String, + userId: Long, + deviceUuid: String, + options: UploadFinishOptions, + ): String { + if (options.videoOptions != null) { + val videoOptions = options.videoOptions + if (videoOptions.clips.isEmpty()) { + videoOptions.clips = listOf(Clip(videoOptions.length, options.sourceType)) + } + } + val timezoneOffset = DateUtils.timezoneOffset.toString() + val form = mutableMapOf( + "timezone_offset" to timezoneOffset, + "_csrftoken" to csrfToken, + "source_type" to options.sourceType, + "_uid" to userId.toString(), + "_uuid" to deviceUuid, + "upload_id" to options.uploadId, + ) + if (options.videoOptions != null) { + form.putAll(options.videoOptions.map) + } + val queryMap = if (options.videoOptions != null) mapOf("video" to "1") else emptyMap() + val signedForm = Utils.sign(form) + return service.uploadFinish(retryContextString, queryMap, signedForm) + } + + suspend fun delete( + csrfToken: String, + userId: Long, + deviceUuid: String, + postId: String, + type: MediaItemType, + ): String? { + if (!DELETABLE_ITEMS_TYPES.contains(type)) return null + val form = mapOf( + "_csrftoken" to csrfToken, + "_uid" to userId, + "_uuid" to deviceUuid, + "igtv_feed_preview" to "false", + "media_id" to postId, + ) + val signedForm = Utils.sign(form) + val mediaType: String = when (type) { + MediaItemType.MEDIA_TYPE_IMAGE -> "PHOTO" + MediaItemType.MEDIA_TYPE_VIDEO -> "VIDEO" + MediaItemType.MEDIA_TYPE_SLIDER -> "CAROUSEL" + else -> return null + } + return service.delete(postId, mediaType, signedForm) + } + + companion object { + @Volatile + private var INSTANCE: MediaRepository? = null + + private val DELETABLE_ITEMS_TYPES = listOf( + MediaItemType.MEDIA_TYPE_IMAGE, + MediaItemType.MEDIA_TYPE_VIDEO, + MediaItemType.MEDIA_TYPE_SLIDER + ) + + fun getInstance(): MediaRepository { + return INSTANCE ?: synchronized(this) { + val service: MediaService = retrofit.create(MediaService::class.java) + MediaRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/NewsService.java b/app/src/main/java/awais/instagrabber/webservices/NewsService.java new file mode 100644 index 0000000..ad8c7b6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/NewsService.java @@ -0,0 +1,197 @@ +package awais.instagrabber.webservices; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import awais.instagrabber.repositories.NewsRepository; +import awais.instagrabber.repositories.responses.AymlResponse; +import awais.instagrabber.repositories.responses.AymlUser; +import awais.instagrabber.repositories.responses.NewsInboxResponse; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.UserSearchResponse; +import awais.instagrabber.repositories.responses.notification.Notification; +import awais.instagrabber.repositories.responses.notification.NotificationArgs; +import awais.instagrabber.repositories.responses.notification.NotificationCounts; +import awais.instagrabber.utils.Constants; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class NewsService { + private static final String TAG = "NewsService"; + + private final NewsRepository repository; + + private static NewsService instance; + + private NewsService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(NewsRepository.class); + } + + public static NewsService getInstance() { + if (instance == null) { + instance = new NewsService(); + } + return instance; + } + + public void fetchAppInbox(final boolean markAsSeen, + final ServiceCallback> callback) { + final Call request = repository.appInbox(markAsSeen, Constants.X_IG_APP_ID); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final NewsInboxResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + final List result = new ArrayList(); + final List newStories = body.getNewStories(); + if (newStories != null) result.addAll(newStories); + final List oldStories = body.getOldStories(); + if (oldStories != null) result.addAll(oldStories); + callback.onSuccess(result); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + // Log.e(TAG, "onFailure: ", t); + } + }); + } + + public void fetchActivityCounts(final ServiceCallback callback) { + final Call request = repository.appInbox(false, null); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final NewsInboxResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(body.getCounts()); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + // Log.e(TAG, "onFailure: ", t); + } + }); + } + + public void fetchSuggestions(final String csrfToken, + final String deviceUuid, + final ServiceCallback> callback) { + final Map form = new HashMap<>(); + form.put("_uuid", UUID.randomUUID().toString()); + form.put("_csrftoken", csrfToken); + form.put("phone_id", UUID.randomUUID().toString()); + form.put("device_id", UUID.randomUUID().toString()); + form.put("module", "discover_people"); + form.put("paginate", "false"); + final Call request = repository.getAyml(form); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final AymlResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + final List aymlUsers = new ArrayList(); + final List newSuggestions = body.getNewSuggestedUsers().getSuggestions(); + if (newSuggestions != null) { + aymlUsers.addAll(newSuggestions); + } + final List oldSuggestions = body.getSuggestedUsers().getSuggestions(); + if (oldSuggestions != null) { + aymlUsers.addAll(oldSuggestions); + } + + final List newsItems = aymlUsers + .stream() + .map(i -> { + final User u = i.getUser(); + return new Notification( + new NotificationArgs( + i.getSocialContext(), + i.getAlgorithm(), + u.getPk(), + u.getProfilePicUrl(), + null, + 0L, + u.getUsername(), + u.getFullName(), + u.isVerified() + ), + 9999, + String.valueOf(u.getPk()) // placeholder + ); + }) + .collect(Collectors.toList()); + callback.onSuccess(newsItems); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + // Log.e(TAG, "onFailure: ", t); + } + }); + } + + public void fetchChaining(final long targetId, final ServiceCallback> callback) { + final Call request = repository.getChaining(targetId); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final UserSearchResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + + final List newsItems = body + .getUsers() + .stream() + .map(u -> { + return new Notification( + new NotificationArgs( + u.getSocialContext(), + null, + u.getPk(), + u.getProfilePicUrl(), + null, + 0L, + u.getUsername(), + u.getFullName(), + u.isVerified() + ), + 9999, + u.getProfilePicId() // placeholder + ); + }) + .collect(Collectors.toList()); + callback.onSuccess(newsItems); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + // Log.e(TAG, "onFailure: ", t); + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/ProfileService.java b/app/src/main/java/awais/instagrabber/webservices/ProfileService.java new file mode 100644 index 0000000..e8f6884 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/ProfileService.java @@ -0,0 +1,299 @@ +package awais.instagrabber.webservices; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableMap; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.repositories.ProfileRepository; +import awais.instagrabber.repositories.responses.Media; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.repositories.responses.UserFeedResponse; +import awais.instagrabber.repositories.responses.WrappedFeedResponse; +import awais.instagrabber.repositories.responses.WrappedMedia; +import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class ProfileService { + private static final String TAG = "ProfileService"; + + private final ProfileRepository repository; + + private static ProfileService instance; + + private ProfileService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(ProfileRepository.class); + } + + public static ProfileService getInstance() { + if (instance == null) { + instance = new ProfileService(); + } + return instance; + } + + public void fetchPosts(final long userId, + final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + final Call request = repository.fetch(userId, builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final UserFeedResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(new PostsFetchResponse( + body.getItems(), + body.getMoreAvailable(), + body.getNextMaxId() + )); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void fetchSaved(final String maxId, + final String collectionId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + Call request = null; + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + if (TextUtils.isEmpty(collectionId) || collectionId.equals("ALL_MEDIA_AUTO_COLLECTION")) request = repository.fetchSaved(builder.build()); + else request = repository.fetchSavedCollection(collectionId, builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final WrappedFeedResponse userFeedResponse = response.body(); + if (userFeedResponse == null) { + callback.onSuccess(null); + return; + } + final List items = userFeedResponse.getItems(); + final List posts; + if (items == null) { + posts = Collections.emptyList(); + } else { + posts = items.stream() + .map(WrappedMedia::getMedia) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + callback.onSuccess(new PostsFetchResponse( + posts, + userFeedResponse.isMoreAvailable(), + userFeedResponse.getNextMaxId() + )); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void fetchCollections(final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + builder.put("collection_types", "[\"ALL_MEDIA_AUTO_COLLECTION\",\"MEDIA\",\"PRODUCT_AUTO_COLLECTION\"]"); + final Call request = repository.fetchCollections(builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final CollectionsListResponse collectionsListResponse = response.body(); + if (collectionsListResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(collectionsListResponse); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void createCollection(final String name, + final String deviceUuid, + final long userId, + final String csrfToken, + final ServiceCallback callback) { + final Map form = new HashMap<>(6); + form.put("_csrftoken", csrfToken); + form.put("_uuid", deviceUuid); + form.put("_uid", userId); + form.put("collection_visibility", "0"); // 1 for public, planned for future but currently inexistant + form.put("module_name", "collection_create"); + form.put("name", name); + final Map signedForm = Utils.sign(form); + final Call request = repository.createCollection(signedForm); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final String collectionsListResponse = response.body(); + if (collectionsListResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(collectionsListResponse); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void fetchLiked(final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + final Call request = repository.fetchLiked(builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final UserFeedResponse userFeedResponse = response.body(); + if (userFeedResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(new PostsFetchResponse( + userFeedResponse.getItems(), + userFeedResponse.getMoreAvailable(), + userFeedResponse.getNextMaxId() + )); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void fetchTagged(final long profileId, + final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + final Call request = repository.fetchTagged(profileId, builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) return; + final UserFeedResponse userFeedResponse = response.body(); + if (userFeedResponse == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(new PostsFetchResponse( + userFeedResponse.getItems(), + userFeedResponse.getMoreAvailable(), + userFeedResponse.getNextMaxId() + )); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + // private PostsFetchResponse parseProfilePostsResponse(final String body) throws JSONException { + // final JSONObject root = new JSONObject(body); + // final boolean moreAvailable = root.optBoolean("more_available"); + // final String nextMaxId = root.optString("next_max_id"); + // final JSONArray itemsJson = root.optJSONArray("items"); + // final List items = parseItems(itemsJson, false); + // return new PostsFetchResponse( + // items, + // moreAvailable, + // nextMaxId + // ); + // } + + // private PostsFetchResponse parseSavedPostsResponse(final String body, final boolean isInMedia) throws JSONException { + // final JSONObject root = new JSONObject(body); + // final boolean moreAvailable = root.optBoolean("more_available"); + // final String nextMaxId = root.optString("next_max_id"); + // final int numResults = root.optInt("num_results"); + // final String status = root.optString("status"); + // final JSONArray itemsJson = root.optJSONArray("items"); + // final List items = parseItems(itemsJson, isInMedia); + // return new PostsFetchResponse( + // items, + // moreAvailable, + // nextMaxId + // ); + // } + + // private List parseItems(final JSONArray items, final boolean isInMedia) throws JSONException { + // if (items == null) { + // return Collections.emptyList(); + // } + // final List feedModels = new ArrayList<>(); + // for (int i = 0; i < items.length(); i++) { + // final JSONObject itemJson = items.optJSONObject(i); + // if (itemJson == null) { + // continue; + // } + // final FeedModel feedModel = ResponseBodyUtils.parseItem(isInMedia ? itemJson.optJSONObject("media") : itemJson); + // if (feedModel != null) { + // feedModels.add(feedModel); + // } + // } + // return feedModels; + // } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt b/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt new file mode 100644 index 0000000..a081644 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt @@ -0,0 +1,57 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.BuildConfig +import awais.instagrabber.repositories.responses.Caption +import awais.instagrabber.repositories.serializers.CaptionDeserializer +import awais.instagrabber.utils.Utils +import awais.instagrabber.webservices.interceptors.AddCookiesInterceptor +import awais.instagrabber.webservices.interceptors.IgErrorsInterceptor +import com.google.gson.FieldNamingPolicy +import com.google.gson.GsonBuilder +import okhttp3.Cache +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.io.File + +object RetrofitFactory { + private const val cacheSize: Long = 10 * 1024 * 1024 // 10 MB + private val cache = Cache(File(Utils.cacheDir), cacheSize) + private val igErrorsInterceptor: IgErrorsInterceptor by lazy { IgErrorsInterceptor() } + + private val retrofitBuilder: Retrofit.Builder by lazy { + val clientBuilder = OkHttpClient.Builder().apply { + followRedirects(false) + followSslRedirects(false) + cache(cache) + addInterceptor(AddCookiesInterceptor()) + addInterceptor(igErrorsInterceptor) + if (BuildConfig.DEBUG) { + // addInterceptor(LoggingInterceptor()) + } + } + val gson = GsonBuilder().apply { + setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + registerTypeAdapter(Caption::class.java, CaptionDeserializer()) + setLenient() + }.create() + Retrofit.Builder().apply { + addConverterFactory(ScalarsConverterFactory.create()) + addConverterFactory(GsonConverterFactory.create(gson)) + client(clientBuilder.build()) + } + } + + val retrofit: Retrofit by lazy { + retrofitBuilder + .baseUrl("https://i.instagram.com") + .build() + } + + val retrofitWeb: Retrofit by lazy { + retrofitBuilder + .baseUrl("https://www.instagram.com") + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/SearchService.java b/app/src/main/java/awais/instagrabber/webservices/SearchService.java new file mode 100644 index 0000000..840a803 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/SearchService.java @@ -0,0 +1,43 @@ +package awais.instagrabber.webservices; + +import com.google.common.collect.ImmutableMap; + +import awais.instagrabber.repositories.SearchRepository; +import awais.instagrabber.repositories.responses.search.SearchResponse; +import retrofit2.Call; + +public class SearchService { + private static final String TAG = "LocationService"; + + private final SearchRepository repository; + + private static SearchService instance; + + private SearchService() { + repository = RetrofitFactory.INSTANCE + .getRetrofitWeb() + .create(SearchRepository.class); + } + + public static SearchService getInstance() { + if (instance == null) { + instance = new SearchService(); + } + return instance; + } + + public Call search(final boolean isLoggedIn, + final String query, + final String context) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + builder.put("query", query); + // context is one of: "blended", "user", "place", "hashtag" + // note that "place" and "hashtag" can contain ONE user result, who knows why + builder.put("context", context); + builder.put("count", "50"); + return repository.search(isLoggedIn + ? "https://i.instagram.com/api/v1/fbsearch/topsearch_flat/" + : "https://www.instagram.com/web/search/topsearch/", + builder.build()); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/ServiceCallback.java b/app/src/main/java/awais/instagrabber/webservices/ServiceCallback.java new file mode 100644 index 0000000..68a4bea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/ServiceCallback.java @@ -0,0 +1,7 @@ +package awais.instagrabber.webservices; + +public interface ServiceCallback { + void onSuccess(T result); + + void onFailure(Throwable t); +} diff --git a/app/src/main/java/awais/instagrabber/webservices/StoriesRepository.kt b/app/src/main/java/awais/instagrabber/webservices/StoriesRepository.kt new file mode 100644 index 0000000..205983c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/StoriesRepository.kt @@ -0,0 +1,202 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.fragments.settings.PreferenceKeys +import awais.instagrabber.repositories.StoriesService +import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.stories.ArchiveResponse +import awais.instagrabber.repositories.responses.stories.Story +import awais.instagrabber.repositories.responses.stories.StoryMedia +import awais.instagrabber.repositories.responses.stories.StoryStickerResponse +import awais.instagrabber.utils.Utils +import awais.instagrabber.webservices.RetrofitFactory.retrofit +import java.util.UUID + +open class StoriesRepository(private val service: StoriesService) { + + suspend fun fetch(mediaId: Long): StoryMedia? { + val response = service.fetch(mediaId) + return response.items?.get(0) + } + + suspend fun getFeedStories(): List { + val response = service.getFeedStories() + val result: MutableList = mutableListOf() + if (response?.broadcasts != null) { + val length = response.broadcasts.size + for (i in 0 until length) { + val broadcast = response.broadcasts.get(i) + result.add( + Story( + broadcast.id, + broadcast.publishedTime, + 1, + 0L, + broadcast.broadcastOwner, + broadcast.muted, + false, // unclear + null, + null, + null, + null, + broadcast + ) + ) + } + } + if (response?.tray != null) result.addAll(response.tray) + return sort(result.toList()) + } + + open suspend fun fetchHighlights(profileId: Long): List { + val response = service.fetchHighlights(profileId) + val highlightModels = response?.tray ?: listOf() + return highlightModels + } + + suspend fun fetchArchive(maxId: String): ArchiveResponse? { + val form = mutableMapOf( + "include_suggested_highlights" to "false", + "is_in_archive_home" to "true", + "include_cover" to "1", + ) + if (!maxId.isNullOrEmpty()) { + form["max_id"] = maxId // NOT TESTED + } + return service.fetchArchive(form) + } + + open suspend fun getStories(options: StoryViewerOptions): Story? { + return when (options.type) { + StoryViewerOptions.Type.HIGHLIGHT, + StoryViewerOptions.Type.STORY_ARCHIVE + -> { + val response = service.getReelsMedia(options.name) + response.reels?.get(options.name) + } + StoryViewerOptions.Type.USER -> { + val response = service.getUserStories(options.id) + response.reel + } + // should not reach beyond this point + StoryViewerOptions.Type.LOCATION -> { + val response = service.getStories("locations", options.id.toString()) + response.story + } + StoryViewerOptions.Type.HASHTAG -> { + val response = service.getStories("tags", options.name) + response.story + } + else -> null + } + } + + private suspend fun respondToSticker( + csrfToken: String, + userId: Long, + deviceUuid: String, + storyId: Long, + stickerId: Long, + action: String, + arg1: String, + arg2: String, + ): StoryStickerResponse { + val form = mapOf( + "_csrftoken" to csrfToken, + "_uid" to userId, + "_uuid" to deviceUuid, + "mutation_token" to UUID.randomUUID().toString(), + "client_context" to UUID.randomUUID().toString(), + "radio_type" to "wifi-none", + arg1 to arg2, + ) + val signedForm = Utils.sign(form) + return service.respondToSticker(storyId, stickerId, action, signedForm) + } + + suspend fun respondToQuestion( + csrfToken: String, + userId: Long, + deviceUuid: String, + storyId: Long, + stickerId: Long, + answer: String, + ): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_question_response", "response", answer) + + suspend fun respondToQuiz( + csrfToken: String, + userId: Long, + deviceUuid: String, + storyId: Long, + stickerId: Long, + answer: Int, + ): StoryStickerResponse { + return respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_quiz_answer", "answer", answer.toString()) + } + + suspend fun respondToPoll( + csrfToken: String, + userId: Long, + deviceUuid: String, + storyId: Long, + stickerId: Long, + answer: Int, + ): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_poll_vote", "vote", answer.toString()) + + suspend fun respondToSlider( + csrfToken: String, + userId: Long, + deviceUuid: String, + storyId: Long, + stickerId: Long, + answer: Double, + ): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_slider_vote", "vote", answer.toString()) + + suspend fun seen( + csrfToken: String, + userId: Long, + deviceUuid: String, + storyMediaId: String, + takenAt: Long, + seenAt: Long, + ): String { + val reelsForm = mapOf(storyMediaId to listOf(takenAt.toString() + "_" + seenAt)) + val form = mutableMapOf( + "_csrftoken" to csrfToken, + "_uid" to userId, + "_uuid" to deviceUuid, + "container_module" to "feed_timeline", + "reels" to reelsForm, + ) + val signedForm = Utils.sign(form) + val queryMap = mapOf( + "reel" to "1", + "live_vod" to "0", + ) + return service.seen(queryMap, signedForm) + } + + private fun sort(list: List): List { + val listCopy = ArrayList(list) + listCopy.sortWith { o1, o2 -> + if (o1.latestReelMedia == null || o2.latestReelMedia == null) return@sortWith 0 + else when (Utils.settingsHelper.getString(PreferenceKeys.STORY_SORT)) { + "1" -> return@sortWith o2.latestReelMedia.compareTo(o1.latestReelMedia) + "2" -> return@sortWith o1.latestReelMedia.compareTo(o2.latestReelMedia) + else -> return@sortWith 0 + } + } + return listCopy + } + + companion object { + @Volatile + private var INSTANCE: StoriesRepository? = null + + fun getInstance(): StoriesRepository { + return INSTANCE ?: synchronized(this) { + val service: StoriesService = retrofit.create(StoriesService::class.java) + StoriesRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/TagsService.java b/app/src/main/java/awais/instagrabber/webservices/TagsService.java new file mode 100644 index 0000000..f207dca --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/TagsService.java @@ -0,0 +1,138 @@ +package awais.instagrabber.webservices; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.common.collect.ImmutableMap; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +import awais.instagrabber.repositories.TagsRepository; +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.PostsFetchResponse; +import awais.instagrabber.repositories.responses.TagFeedResponse; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class TagsService { + + private static final String TAG = "TagsService"; + + private static TagsService instance; + + private final TagsRepository repository; + + private TagsService() { + repository = RetrofitFactory.INSTANCE + .getRetrofit() + .create(TagsRepository.class); + } + + public static TagsService getInstance() { + if (instance == null) { + instance = new TagsService(); + } + return instance; + } + + public void fetch(@NonNull final String tag, + final ServiceCallback callback) { + final Call request = repository.fetch(tag); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) { + return; + } + callback.onSuccess(response.body()); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + public void changeFollow(@NonNull final String action, + @NonNull final String tag, + @NonNull final String csrfToken, + @NonNull final long userId, + @NonNull final String deviceUuid, + final ServiceCallback callback) { + final Map form = new HashMap<>(3); + form.put("_csrftoken", csrfToken); + form.put("_uid", userId); + form.put("_uuid", deviceUuid); + final Map signedForm = Utils.sign(form); + final Call request = repository.changeFollow(signedForm, action, tag); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) { + callback.onFailure(new RuntimeException("body is null")); + return; + } + try { + final JSONObject jsonObject = new JSONObject(body); + final String status = jsonObject.optString("status"); + callback.onSuccess(status.equals("ok")); + } catch (JSONException e) { + Log.e(TAG, "onResponse: ", e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + // Log.e(TAG, "onFailure: ", t); + callback.onFailure(t); + } + }); + } + + public void fetchPosts(@NonNull final String tag, + final String maxId, + final ServiceCallback callback) { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (!TextUtils.isEmpty(maxId)) { + builder.put("max_id", maxId); + } + final Call request = repository.fetchPosts(tag, builder.build()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (callback == null) { + return; + } + final TagFeedResponse body = response.body(); + if (body == null) { + callback.onSuccess(null); + return; + } + callback.onSuccess(new PostsFetchResponse( + body.getItems(), + body.getMoreAvailable(), + body.getNextMaxId() + )); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/UserRepository.kt b/app/src/main/java/awais/instagrabber/webservices/UserRepository.kt new file mode 100644 index 0000000..8af65ef --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/UserRepository.kt @@ -0,0 +1,40 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.repositories.UserService +import awais.instagrabber.repositories.responses.FriendshipStatus +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.UserSearchResponse +import awais.instagrabber.webservices.RetrofitFactory.retrofit +import java.util.* + +open class UserRepository(private val service: UserService) { + + suspend fun getUserInfo(uid: Long): User { + val response = service.getUserInfo(uid) + return response.user + } + + open suspend fun getUsernameInfo(username: String): User { + val response = service.getUsernameInfo(username) + return response.user + } + + open suspend fun getUserFriendship(uid: Long): FriendshipStatus = service.getUserFriendship(uid) + + suspend fun search(query: String): UserSearchResponse { + val timezoneOffset = TimeZone.getDefault().rawOffset.toFloat() / 1000 + return service.search(timezoneOffset, query) + } + + companion object { + @Volatile + private var INSTANCE: UserRepository? = null + + fun getInstance(): UserRepository { + return INSTANCE ?: synchronized(this) { + val service: UserService = retrofit.create(UserService::class.java) + UserRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/interceptors/AddCookiesInterceptor.java b/app/src/main/java/awais/instagrabber/webservices/interceptors/AddCookiesInterceptor.java new file mode 100644 index 0000000..610b5f8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/interceptors/AddCookiesInterceptor.java @@ -0,0 +1,37 @@ +package awais.instagrabber.webservices.interceptors; + +import androidx.annotation.NonNull; + +import java.io.IOException; + +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.LocaleUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +public class AddCookiesInterceptor implements Interceptor { + @NonNull + @Override + public Response intercept(@NonNull final Chain chain) throws IOException { + final Request request = chain.request(); + final Request.Builder builder = request.newBuilder(); + final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); + final boolean hasCookie = !TextUtils.isEmpty(cookie); + if (hasCookie) { + builder.addHeader("Cookie", cookie); + } + final String userAgentHeader = "User-Agent"; + if (request.header(userAgentHeader) == null) { + builder.addHeader(userAgentHeader, Utils.settingsHelper.getString(hasCookie ? Constants.APP_UA : Constants.BROWSER_UA)); + } + final String languageHeader = "Accept-Language"; + if (request.header(languageHeader) == null) { + builder.addHeader(languageHeader, LocaleUtils.getCurrentLocale().getLanguage() + ",en-US;q=0.8"); + } + final Request updatedRequest = builder.build(); + return chain.proceed(updatedRequest); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/interceptors/IgErrorsInterceptor.java b/app/src/main/java/awais/instagrabber/webservices/interceptors/IgErrorsInterceptor.java new file mode 100644 index 0000000..8dcd673 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/interceptors/IgErrorsInterceptor.java @@ -0,0 +1,162 @@ +package awais.instagrabber.webservices.interceptors; + +import android.text.Html; +import android.text.Spanned; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.fragment.app.FragmentManager; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.dialogs.ConfirmDialogFragment; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.TextUtils; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class IgErrorsInterceptor implements Interceptor { + private static final String TAG = IgErrorsInterceptor.class.getSimpleName(); + + public IgErrorsInterceptor() { } + + @NonNull + @Override + public Response intercept(@NonNull final Chain chain) throws IOException { + final Request request = chain.request(); + final Response response = chain.proceed(request); + if (response.isSuccessful()) { + return response; + } + checkError(response); + return response; + } + + private void checkError(@NonNull final Response response) { + final int errorCode = response.code(); + switch (errorCode) { + case 429: // "429 Too Many Requests" + // ('Throttled by Instagram because of too many API requests.'); + showErrorDialog(R.string.throttle_error); + return; + case 431: // "431 Request Header Fields Too Large" + // show dialog? + Log.e(TAG, "Network error: " + getMessage(errorCode, "The request start-line and/or headers are too large to process.")); + return; + case 404: + showErrorDialog(R.string.not_found); + return; + case 302: // redirect + final String location = response.header("location"); + if (location != null && location.equals("https://www.instagram.com/accounts/login/")) { + // rate limited + final String message = MainActivity.getInstance().getString(R.string.rate_limit); + final Spanned spanned = Html.fromHtml(message); + showErrorDialog(spanned); + } + return; + } + final ResponseBody body = response.body(); + if (body == null) return; + try { + final String bodyString = body.string(); + Log.d(TAG, "checkError: " + bodyString); + JSONObject jsonObject = null; + try { + jsonObject = new JSONObject(bodyString); + } catch (JSONException e) { + Log.e(TAG, "checkError: ", e); + } + String message; + if (jsonObject != null) { + message = jsonObject.optString("message"); + } else { + message = bodyString; + } + if (!TextUtils.isEmpty(message)) { + message = message.toLowerCase(); + switch (message) { + case "user_has_logged_out": + showErrorDialog(R.string.account_logged_out); + return; + case "login_required": + showErrorDialog(R.string.login_required); + return; + case "execution failure": + showSnackbar(message); + return; + case "not authorized to view user": // Do we handle this in profile view fragment? + case "challenge_required": // Since we make users login using browser, we should not be getting this error in api requests + default: + showSnackbar(message); + Log.e(TAG, "checkError: " + bodyString); + return; + } + } + final String errorType = jsonObject.optString("error_type"); + if (TextUtils.isEmpty(errorType)) return; + if (errorType.equals("sentry_block")) { + showErrorDialog("\"sentry_block\". Please contact developers."); + return; + } + if (errorType.equals("inactive user")) { + showErrorDialog(R.string.inactive_user); + } + } catch (Exception e) { + Log.e(TAG, "checkError: ", e); + } + } + + private void showSnackbar(final String message) { + final MainActivity mainActivity = MainActivity.getInstance(); + if (mainActivity == null) return; + // final View view = mainActivity.getRootView(); + // if (view == null) return; + try { + AppExecutors.INSTANCE + .getMainThread() + .execute(() -> Toast.makeText(mainActivity.getApplicationContext(), message, Toast.LENGTH_LONG).show()); + } catch (Exception e) { + Log.e(TAG, "showSnackbar: ", e); + } + } + + @NonNull + private String getMessage(final int errorCode, final String message) { + return String.format("code: %s, internalMessage: %s", errorCode, message); + } + + private void showErrorDialog(@NonNull final CharSequence message) { + final MainActivity mainActivity = MainActivity.getInstance(); + if (mainActivity == null) return; + final FragmentManager fragmentManager = mainActivity.getSupportFragmentManager(); + if (fragmentManager.isStateSaved()) return; + final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( + Constants.GLOBAL_NETWORK_ERROR_DIALOG_REQUEST_CODE, + R.string.error, + message, + R.string.ok, + 0, + 0 + ); + dialogFragment.show(fragmentManager, "network_error_dialog"); + } + + private void showErrorDialog(@StringRes final int messageResId) { + showErrorDialog(MainActivity.getInstance().getString(messageResId)); + } + + public void destroy() { + // mainActivity = null; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/interceptors/LoggingInterceptor.java b/app/src/main/java/awais/instagrabber/webservices/interceptors/LoggingInterceptor.java new file mode 100644 index 0000000..02d02ba --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/interceptors/LoggingInterceptor.java @@ -0,0 +1,45 @@ +package awais.instagrabber.webservices.interceptors; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class LoggingInterceptor implements Interceptor { + private static final String TAG = "LoggingInterceptor"; + + @NonNull + @Override + public Response intercept(Interceptor.Chain chain) throws IOException { + final Request request = chain.request(); + long t1 = System.nanoTime(); + Log.i(TAG, String.format("Sending request %s on %s%n%s", + request.url(), chain.connection(), request.headers())); + final Response response = chain.proceed(request); + long t2 = System.nanoTime(); + Log.i(TAG, String.format("Received response for %s in %.1fms%n%s", response.request().url(), (t2 - t1) / 1e6d, response.headers())); + final ResponseBody body = response.body(); + MediaType contentType = null; + String content = ""; + if (body != null) { + contentType = body.contentType(); + try { + content = body.string(); + } catch (Exception e) { + Log.e(TAG, "intercept: ", e); + } + Log.d(TAG, content); + } + final ResponseBody wrappedBody = ResponseBody.create(contentType, content); + return response.newBuilder() + .body(wrappedBody) + .build(); + } +} diff --git a/app/src/main/java/awais/instagrabber/workers/DownloadWorker.kt b/app/src/main/java/awais/instagrabber/workers/DownloadWorker.kt new file mode 100644 index 0000000..e0c8066 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/workers/DownloadWorker.kt @@ -0,0 +1,431 @@ +package awais.instagrabber.workers + +import android.app.Notification +import android.app.PendingIntent +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.documentfile.provider.DocumentFile +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import awais.instagrabber.BuildConfig +import awais.instagrabber.R +import awais.instagrabber.services.DeleteImageIntentService +import awais.instagrabber.utils.BitmapUtils +import awais.instagrabber.utils.Constants.DOWNLOAD_CHANNEL_ID +import awais.instagrabber.utils.Constants.NOTIF_GROUP_NAME +import awais.instagrabber.utils.DownloadUtils +import awais.instagrabber.utils.TextUtils.isEmpty +import awais.instagrabber.utils.Utils +import awais.instagrabber.utils.extensions.TAG +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.imaging.formats.jpeg.iptc.JpegIptcRewriter +import java.io.BufferedInputStream +import java.io.File +import java.net.URL +import java.util.* +import java.util.concurrent.ExecutionException +import java.util.stream.Collectors +import kotlin.collections.Map +import kotlin.math.abs + +class DownloadWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context) + + override suspend fun doWork(): Result { + val downloadRequestFilePath = inputData.getString(KEY_DOWNLOAD_REQUEST_JSON) + if (downloadRequestFilePath.isNullOrBlank()) { + return Result.failure(Data.Builder() + .putString("error", "downloadRequest is empty or null") + .build()) + } + val downloadRequestString: String + val requestFile = Uri.parse(downloadRequestFilePath) + val context = applicationContext + val contentResolver = context.contentResolver ?: return Result.failure(Data.Builder() + .putString("error", "contentResolver is null") + .build()) + try { + val scanner = Scanner(contentResolver.openInputStream(requestFile)) + downloadRequestString = scanner.useDelimiter("\\A").next() + } catch (e: Exception) { + Log.e(TAG, "doWork: ", e) + return Result.failure(Data.Builder() + .putString("error", e.localizedMessage) + .build()) + } + if (downloadRequestString.isBlank()) { + return Result.failure(Data.Builder() + .putString("error", "downloadRequest is empty") + .build()) + } + val downloadRequest: DownloadRequest = try { + Gson().fromJson(downloadRequestString, DownloadRequest::class.java) + } catch (e: JsonSyntaxException) { + Log.e(TAG, "doWork", e) + return Result.failure(Data.Builder() + .putString("error", e.localizedMessage) + .build()) + } ?: return Result.failure(Data.Builder() + .putString("error", "downloadRequest is null") + .build()) + val urlToFilePathMap = downloadRequest.urlToFilePathMap + download(urlToFilePathMap) + Handler(Looper.getMainLooper()).postDelayed({ showSummary(urlToFilePathMap) }, 500) + val deleted = DocumentFile.fromSingleUri(context, requestFile)!!.delete() + if (!deleted) { + Log.w(TAG, "doWork: requestFile not deleted!") + } + return Result.success() + } + + private suspend fun download(urlToFilePathMap: Map) { + val notificationId = notificationId + val entries = urlToFilePathMap.entries + var count = 1 + val total = urlToFilePathMap.size + for ((url, uriString) in entries) { + updateDownloadProgress(notificationId, count, total, 0f) + withContext(Dispatchers.IO) { + val file = DocumentFile.fromSingleUri(applicationContext, Uri.parse(uriString)) + download(notificationId, count, total, url, file!!) + } + count++ + } + } + + private val notificationId: Int + get() = abs(id.hashCode()) + + private fun download( + notificationId: Int, + position: Int, + total: Int, + url: String, + filePath: DocumentFile, + ) { + val context = applicationContext.let { it } + val contentResolver = context.contentResolver?.let { it } ?: return + val filePathType = filePath.type?.let { it } ?: return + val isJpg = filePathType.startsWith("image") + // using temp file approach to remove IPTC so that download progress can be reported + val outFile = if (isJpg) DownloadUtils.getTempFile(null, "jpg") else filePath + try { + val urlConnection = URL(url).openConnection() + val fileSize = if (Build.VERSION.SDK_INT >= 24) urlConnection.contentLengthLong else urlConnection.contentLength.toLong() + var totalRead = 0f + try { + BufferedInputStream(urlConnection.getInputStream()).use { bis -> + contentResolver.openOutputStream(outFile!!.uri).use { fos -> + val buffer = ByteArray(0x2000) + var count: Int + while (bis.read(buffer, 0, 0x2000).also { count = it } != -1) { + totalRead += count + fos!!.write(buffer, 0, count) + setProgressAsync(Data.Builder().putString(URL, url) + .putFloat(PROGRESS, totalRead * 100f / fileSize) + .build()) + updateDownloadProgress(notificationId, position, total, totalRead * 100f / fileSize) + } + fos!!.flush() + } + } + } catch (e: Exception) { + Log.e(TAG, "Error while writing data from url: " + url + " to file: " + outFile!!.name, e) + } + if (isJpg) { + try { + contentResolver.openInputStream(outFile!!.uri).use { fis -> + contentResolver.openOutputStream(filePath.uri).use { fos -> + val jpegIptcRewriter = JpegIptcRewriter() + jpegIptcRewriter.removeIPTC(fis, fos) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error while removing iptc: url: " + url + + ", tempFile: " + outFile!!.name + + ", finalFile: " + filePath.name, e) + } + val deleted = outFile!!.delete() + if (!deleted) { + Log.w(TAG, "download: tempFile not deleted!") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error while downloading: $url", e) + } + setProgressAsync(Data.Builder().putString(URL, url) + .putFloat(PROGRESS, 100f) + .build()) + updateDownloadProgress(notificationId, position, total, 100f) + } + + private fun updateDownloadProgress( + notificationId: Int, + position: Int, + total: Int, + percent: Float, + ) { + val notification = createProgressNotification(position, total, percent) + try { + if (notification == null) { + notificationManager.cancel(notificationId) + return + } + setForegroundAsync(ForegroundInfo(notificationId, notification)).get() + } catch (e: ExecutionException) { + Log.e(TAG, "updateDownloadProgress", e) + } catch (e: InterruptedException) { + Log.e(TAG, "updateDownloadProgress", e) + } + } + + private fun createProgressNotification(position: Int, total: Int, percent: Float): Notification? { + val context = applicationContext + var ongoing = true + val totalPercent: Int + if (position == total && percent == 100f) { + ongoing = false + totalPercent = 100 + } else { + totalPercent = (100f * (position - 1) / total + 1f / total * percent).toInt() + } + if (totalPercent == 100) { + return null + } + // Log.d(TAG, "createProgressNotification: position: " + position + // + ", total: " + total + // + ", percent: " + percent + // + ", totalPercent: " + totalPercent); + val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setSmallIcon(R.drawable.ic_download) + .setOngoing(ongoing) + .setProgress(100, totalPercent, totalPercent < 0) + .setAutoCancel(false) + .setOnlyAlertOnce(true) + .setContentTitle(context.getString(R.string.downloader_downloading_post)) + if (total > 1) { + builder.setContentText(context.getString(R.string.downloader_downloading_child, position, total)) + } + return builder.build() + } + + private fun showSummary(urlToFilePathMap: Map) { + val context = applicationContext + val filePaths = urlToFilePathMap.mapNotNull { DocumentFile.fromSingleUri(context, Uri.parse(it.value)) } + val notifications: MutableList = LinkedList() + val notificationIds: MutableList = LinkedList() + var count = 1 + for (filePath: DocumentFile in filePaths) { + // final File file = new File(filePath); + // context.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, filePath.getUri())); + // Utils.scanDocumentFile(context, filePath, (path, uri) -> {}); + // final Uri uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file); + val contentResolver = context.contentResolver + var bitmap: Bitmap? = null + val mimeType = filePath.type // Utils.getMimeType(uri, contentResolver); + if (!isEmpty(mimeType)) { + if (mimeType!!.startsWith("image")) { + try { + contentResolver.openInputStream(filePath.uri).use { inputStream -> + bitmap = BitmapFactory.decodeStream(inputStream) + } + } catch (e: java.lang.Exception) { + if (BuildConfig.DEBUG) Log.e(TAG, "", e) + } + } else if (mimeType.startsWith("video")) { + val retriever = MediaMetadataRetriever() + try { + try { + retriever.setDataSource(context, filePath.uri) + } catch (e: java.lang.Exception) { + // retriever.setDataSource(file.getAbsolutePath()); + Log.e(TAG, "showSummary: ", e) + } + bitmap = retriever.frameAtTime + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) try { + retriever.close() + } catch (e: java.lang.Exception) { + Log.e(TAG, "showSummary: ", e) + } + } catch (e: java.lang.Exception) { + Log.e(TAG, "", e) + } + } + } + val downloadComplete = context.getString(R.string.downloader_complete) + val intent = Intent(Intent.ACTION_VIEW, filePath.uri) + .addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_FROM_BACKGROUND + or Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + .putExtra(Intent.EXTRA_STREAM, filePath.uri) + val pendingIntent = PendingIntent.getActivity( + context, + DOWNLOAD_NOTIFICATION_INTENT_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT + ) + val notificationId: Int = notificationId + count + notificationIds.add(notificationId) + count++ + val builder: NotificationCompat.Builder = + NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_download) + .setContentText(null) + .setContentTitle(downloadComplete) + .setWhen(System.currentTimeMillis()) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setGroup(NOTIF_GROUP_NAME + "_" + id) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setContentIntent(pendingIntent) + .addAction( + R.drawable.ic_delete, + context.getString(R.string.delete), + DeleteImageIntentService.pendingIntent(context, filePath, notificationId) + ) + if (bitmap != null) { + builder.setLargeIcon(bitmap) + .setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(bitmap) + .bigLargeIcon(null) + ) + .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) + } + notifications.add(builder) + } + var summaryNotification: Notification? = null + if (urlToFilePathMap.size != 1) { + val text = "Downloaded " + urlToFilePathMap.size + " items" + summaryNotification = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) + .setContentTitle("Downloaded") + .setContentText(text) + .setSmallIcon(R.drawable.ic_download) + .setStyle(NotificationCompat.InboxStyle().setSummaryText(text)) + .setGroup(NOTIF_GROUP_NAME + "_" + id) + .setGroupSummary(true) + .build() + } + for (i in notifications.indices) { + val builder = notifications[i] + // only make sound and vibrate for the last notification + if (i != notifications.size - 1) { + builder.setSound(null) + .setVibrate(null) + } + notificationManager.notify(notificationIds[i], builder.build()) + } + if (summaryNotification != null) { + notificationManager.notify(notificationId + count, summaryNotification) + } + } + + private fun getThumbnail( + context: Context, + file: File, + uri: Uri, + contentResolver: ContentResolver, + ): Bitmap? { + val mimeType = Utils.getMimeType(uri, contentResolver) + if (isEmpty(mimeType)) return null + var bitmap: Bitmap? = null + if (mimeType.startsWith("image")) { + try { + val bitmapResult = BitmapUtils.getBitmapResult( + context.contentResolver, + uri, + BitmapUtils.THUMBNAIL_SIZE, + BitmapUtils.THUMBNAIL_SIZE, + -1f, + true + ) ?: return null + bitmap = bitmapResult.bitmap + } catch (e: Exception) { + Log.e(TAG, "", e) + } + return bitmap + } + if (mimeType.startsWith("video")) { + try { + val retriever = MediaMetadataRetriever() + bitmap = try { + try { + retriever.setDataSource(context, uri) + } catch (e: Exception) { + retriever.setDataSource(file.absolutePath) + } + retriever.frameAtTime + } finally { + try { + retriever.release() + } catch (e: Exception) { + Log.e(TAG, "getThumbnail: ", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "", e) + } + } + return bitmap + } + + class DownloadRequest private constructor(val urlToFilePathMap: Map) { + + class Builder { + private var urlToFilePathMap: MutableMap = mutableMapOf() + fun setUrlToFilePathMap(urlToFilePathMap: Map): Builder { + this.urlToFilePathMap = urlToFilePathMap + .filter{ it.value != null } + .mapValues { it.value!!.uri.toString() } + .toMutableMap() + return this + } + + fun addUrl(url: String, filePath: DocumentFile): Builder { + urlToFilePathMap[url] = filePath.uri.toString() + return this + } + + fun build(): DownloadRequest { + return DownloadRequest(urlToFilePathMap) + } + } + + companion object { + @JvmStatic + fun builder(): Builder { + return Builder() + } + } + } + + companion object { + const val PROGRESS = "PROGRESS" + const val URL = "URL" + const val KEY_DOWNLOAD_REQUEST_JSON = "download_request_json" + private const val DOWNLOAD_GROUP = "DOWNLOAD_GROUP" + private const val DOWNLOAD_NOTIFICATION_INTENT_REQUEST_CODE = 2020 + private const val DELETE_IMAGE_REQUEST_CODE = 2030 + } + +} \ No newline at end of file diff --git a/app/src/main/java/awaisomereport/CrashReporter.kt b/app/src/main/java/awaisomereport/CrashReporter.kt new file mode 100755 index 0000000..e6cf782 --- /dev/null +++ b/app/src/main/java/awaisomereport/CrashReporter.kt @@ -0,0 +1,40 @@ +package awaisomereport + +import android.app.Application + +class CrashReporter private constructor(application: Application) : Thread.UncaughtExceptionHandler { + + private val crashHandler: CrashHandler? + private var startAttempted = false + private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null + + init { + crashHandler = CrashHandler(application) + } + + fun start() { + if (startAttempted) return + startAttempted = true + defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + } + + override fun uncaughtException(t: Thread, exception: Throwable) { + if (crashHandler == null) { + defaultExceptionHandler?.uncaughtException(t, exception) + return + } + crashHandler.uncaughtException(t, exception, defaultExceptionHandler ?: return) + } + + companion object { + @Volatile + private var INSTANCE: CrashReporter? = null + + fun getInstance(application: Application): CrashReporter { + return INSTANCE ?: synchronized(this) { + CrashReporter(application).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awaisomereport/CrashReporterHelper.kt b/app/src/main/java/awaisomereport/CrashReporterHelper.kt new file mode 100644 index 0000000..3991f36 --- /dev/null +++ b/app/src/main/java/awaisomereport/CrashReporterHelper.kt @@ -0,0 +1,148 @@ +package awaisomereport + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import awais.instagrabber.BuildConfig +import awais.instagrabber.R +import awais.instagrabber.utils.Constants +import awais.instagrabber.utils.extensions.TAG +import java.io.* +import java.time.LocalDateTime + +object CrashReporterHelper { + private val shortBorder = "=".repeat(14) + private val longBorder = "=".repeat(21) + private const val prefix = "stack-" + private const val suffix = ".stacktrace" + + fun startErrorReporterActivity( + application: Application, + exception: Throwable + ) { + try { + application.openFileOutput( + "$prefix${System.currentTimeMillis()}$suffix", + Context.MODE_PRIVATE + ).use { it.write(getReportContent(exception).toByteArray()) } + } catch (ex: Exception) { + if (BuildConfig.DEBUG) Log.e(TAG, "", ex) + } + application.startActivity(Intent(application, ErrorReporterActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun getReportContent(exception: Throwable): String { + var reportContent = + """ + IMPORTANT: If sending by email, your email address and the entire content will be made public at https://github.com/austinhuang0131/barinsta/issues. + + When possible, please describe the steps leading to this crash. Thank you for your cooperation. + + Error report collected on: ${LocalDateTime.now()} + + Information: + $shortBorder + VERSION : ${BuildConfig.VERSION_NAME} + VERSION_CODE : ${BuildConfig.VERSION_CODE} + PHONE-MODEL : ${Build.MODEL} + ANDROID_VERS : ${Build.VERSION.RELEASE} + ANDROID_REL : ${Build.VERSION.SDK_INT} + BRAND : ${Build.BRAND} + MANUFACTURER : ${Build.MANUFACTURER} + BOARD : ${Build.BOARD} + DEVICE : ${Build.DEVICE} + PRODUCT : ${Build.PRODUCT} + HOST : ${Build.HOST} + TAGS : ${Build.TAGS} + + Stack: + $shortBorder + """.trimIndent() + reportContent = "$reportContent${getStackStrace(exception)}\n\n*** End of current Report ***" + return reportContent.replace("\n", "\r\n") + } + + private fun getStackStrace(exception: Throwable): String { + val writer = StringWriter() + return PrintWriter(writer).use { + val reportBuilder = StringBuilder("\n") + exception.printStackTrace(it) + reportBuilder.append(writer.toString()) + var cause = exception.cause + if (cause != null) reportBuilder.append("\nCause:\n$shortBorder\n") + while (cause != null) { + cause.printStackTrace(it) + reportBuilder.append(writer.toString()) + cause = cause.cause + } + return@use reportBuilder.toString() + } + } + + @JvmStatic + fun startCrashEmailIntent(context: Context) { + try { + val filePath = context.filesDir.absolutePath + val errorFileList: Array? = try { + val dir = File(filePath) + if (dir.exists() && !dir.isDirectory) { + dir.delete() + } + dir.mkdirs() + dir.list { _: File?, name: String -> name.endsWith(suffix) } + } catch (e: Exception) { + null + } + if (errorFileList == null || errorFileList.isEmpty()) { + return + } + val errorStringBuilder: StringBuilder = StringBuilder("\n\n") + val maxSendMail = 5 + for ((curIndex, curString) in errorFileList.withIndex()) { + val file = File("$filePath/$curString") + if (curIndex <= maxSendMail) { + errorStringBuilder.append("New Trace collected:\n$longBorder\n") + BufferedReader(FileReader(file)).use { input -> + var line: String? + while (input.readLine().also { line = it } != null) errorStringBuilder.append(line).append("\n") + } + } + file.delete() + } + errorStringBuilder.append("\n\n") + val resources = context.resources + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + putExtra(Intent.EXTRA_EMAIL, arrayOf(Constants.CRASH_REPORT_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, resources.getString(R.string.crash_report_subject)) + putExtra(Intent.EXTRA_TEXT, errorStringBuilder.toString().replace("\n", "\r\n")) + }, + context.resources.getString(R.string.crash_report_title) + ) + ) + } catch (e: Exception) { + Log.e(TAG, "", e) + } + } + + @JvmStatic + fun deleteAllStacktraceFiles(context: Context) { + val filePath = context.filesDir.absolutePath + val errorFileList: Array? = try { + val dir = File(filePath) + if (dir.exists() && !dir.isDirectory) { + dir.delete() + } + dir.mkdirs() + dir.listFiles { _: File?, name: String -> name.endsWith(suffix) } + } catch (e: Exception) { + null + } + errorFileList?.forEach { it.delete() } + } +} \ No newline at end of file diff --git a/app/src/main/java/awaisomereport/ErrorReporterActivity.kt b/app/src/main/java/awaisomereport/ErrorReporterActivity.kt new file mode 100755 index 0000000..9c58628 --- /dev/null +++ b/app/src/main/java/awaisomereport/ErrorReporterActivity.kt @@ -0,0 +1,96 @@ +package awaisomereport + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ImageSpan +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import awais.instagrabber.R +import awais.instagrabber.databinding.ActivityCrashErrorBinding +import awaisomereport.CrashReporterHelper.startCrashEmailIntent +import java.lang.ref.WeakReference +import kotlin.system.exitProcess + +class ErrorReporterActivity : Activity(), View.OnClickListener { + + private lateinit var binding: ActivityCrashErrorBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityCrashErrorBinding.inflate(layoutInflater) + setContentView(binding.root) + setFinishOnTouchOutside(false) + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + val crashTitle = SpannableString(" " + getString(R.string.crash_title)) + crashTitle.setSpan( + CenteredImageSpan(this, android.R.drawable.stat_notify_error), + 0, + 1, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + title = crashTitle + binding.btnReport.setOnClickListener(this) + binding.btnCancel.setOnClickListener(this) + } + + override fun onClick(v: View) { + if (v === binding.btnReport) { + startCrashEmailIntent(this) + } + finish() + exitProcess(10) + } + + private class CenteredImageSpan(context: Context, @DrawableRes drawableRes: Int) : ImageSpan(context, drawableRes) { + + private var drawable: WeakReference? = null + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: FontMetricsInt? + ): Int { + fm?.apply { + val pfm = paint.fontMetricsInt + ascent = pfm.ascent + descent = pfm.descent + top = pfm.top + bottom = pfm.bottom + } + return cachedDrawable.bounds.right + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + canvas.save() + val drawableHeight = cachedDrawable.intrinsicHeight + val fontMetricsInt = paint.fontMetricsInt + val transY = bottom - cachedDrawable.bounds.bottom + (drawableHeight - fontMetricsInt.descent + fontMetricsInt.ascent) / 2 + canvas.translate(x, transY.toFloat()) + cachedDrawable.draw(canvas) + canvas.restore() + } + + private val cachedDrawable: Drawable + get() = drawable?.get() ?: getDrawable().also { drawable = WeakReference(it) } + } +} \ No newline at end of file diff --git a/app/src/main/java/awaisomereport/ICrashHandler.kt b/app/src/main/java/awaisomereport/ICrashHandler.kt new file mode 100644 index 0000000..d6aa503 --- /dev/null +++ b/app/src/main/java/awaisomereport/ICrashHandler.kt @@ -0,0 +1,9 @@ +package awaisomereport + +interface ICrashHandler { + fun uncaughtException( + t: Thread, + exception: Throwable, + defaultExceptionHandler: Thread.UncaughtExceptionHandler + ) +} \ No newline at end of file diff --git a/app/src/main/java/thoughtbot/expandableadapter/ExpandableGroup.java b/app/src/main/java/thoughtbot/expandableadapter/ExpandableGroup.java new file mode 100755 index 0000000..495f2db --- /dev/null +++ b/app/src/main/java/thoughtbot/expandableadapter/ExpandableGroup.java @@ -0,0 +1,30 @@ +package thoughtbot.expandableadapter; + +import java.util.List; + +import awais.instagrabber.repositories.responses.User; + +public class ExpandableGroup { + private final String title; + private final List items; + + public ExpandableGroup(final String title, final List items) { + this.title = title; + this.items = items; + } + + public String getTitle() { + return title; + } + + public List getItems() { + return items; + } + + public int getItemCount() { + if (items != null) { + return items.size(); + } + return 0; + } +} \ No newline at end of file diff --git a/app/src/main/java/thoughtbot/expandableadapter/ExpandableList.java b/app/src/main/java/thoughtbot/expandableadapter/ExpandableList.java new file mode 100755 index 0000000..5006fb6 --- /dev/null +++ b/app/src/main/java/thoughtbot/expandableadapter/ExpandableList.java @@ -0,0 +1,59 @@ +package thoughtbot.expandableadapter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; + +public final class ExpandableList { + private final int groupsSize; + public final ArrayList groups; + public final boolean[] expandedGroupIndexes; + + public ExpandableList(@NonNull final ArrayList groups) { + this.groups = groups; + this.groupsSize = groups.size(); + this.expandedGroupIndexes = new boolean[groupsSize]; + } + + public ExpandableList(@NonNull final ArrayList groups, + @Nullable final boolean[] expandedGroupIndexes) { + this.groups = groups; + this.groupsSize = groups.size(); + this.expandedGroupIndexes = expandedGroupIndexes; + } + + public int getVisibleItemCount() { + int count = 0; + for (int i = 0; i < groupsSize; i++) count = count + numberOfVisibleItemsInGroup(i); + return count; + } + + @NonNull + public ExpandableListPosition getUnflattenedPosition(final int flPos) { + int adapted = flPos; + for (int i = 0; i < groupsSize; i++) { + final int groupItemCount = numberOfVisibleItemsInGroup(i); + if (adapted == 0) + return ExpandableListPosition.obtain(ExpandableListPosition.GROUP, i, -1, flPos); + else if (adapted < groupItemCount) + return ExpandableListPosition.obtain(ExpandableListPosition.CHILD, i, adapted - 1, flPos); + adapted = adapted - groupItemCount; + } + throw new RuntimeException("Unknown state"); + } + + private int numberOfVisibleItemsInGroup(final int group) { + return expandedGroupIndexes[group] ? groups.get(group).getItemCount() + 1 : 1; + } + + public int getFlattenedGroupIndex(@NonNull final ExpandableListPosition listPosition) { + int runningTotal = 0; + for (int i = 0; i < listPosition.groupPos; i++) runningTotal = runningTotal + numberOfVisibleItemsInGroup(i); + return runningTotal; + } + + public ExpandableGroup getExpandableGroup(@NonNull ExpandableListPosition listPosition) { + return groups.get(listPosition.groupPos); + } +} \ No newline at end of file diff --git a/app/src/main/java/thoughtbot/expandableadapter/ExpandableListPosition.java b/app/src/main/java/thoughtbot/expandableadapter/ExpandableListPosition.java new file mode 100755 index 0000000..3a2e33e --- /dev/null +++ b/app/src/main/java/thoughtbot/expandableadapter/ExpandableListPosition.java @@ -0,0 +1,41 @@ +package thoughtbot.expandableadapter; + +import androidx.annotation.NonNull; + +public class ExpandableListPosition { + private static final ExpandableListPosition LIST_POSITION = new ExpandableListPosition(); + public final static int CHILD = 1; + public final static int GROUP = 2; + private int flatListPos; + public int groupPos; + public int childPos; + public int type; + + @NonNull + public static ExpandableListPosition obtain(final int type, final int groupPos, final int childPos, final int flatListPos) { + LIST_POSITION.type = type; + LIST_POSITION.groupPos = groupPos; + LIST_POSITION.childPos = childPos; + LIST_POSITION.flatListPos = flatListPos; + return LIST_POSITION; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + //if (o != null && getClass() == o.getClass()) { + if (o instanceof ExpandableListPosition) { + final ExpandableListPosition that = (ExpandableListPosition) o; + if (groupPos != that.groupPos) return false; + if (childPos != that.childPos) return false; + if (flatListPos != that.flatListPos) return false; + return type == that.type; + } + return false; + } + + @Override + public int hashCode() { + return 31 * (31 * (31 * groupPos + childPos) + flatListPos) + type; + } +} \ No newline at end of file diff --git a/app/src/main/java/thoughtbot/expandableadapter/GroupViewHolder.java b/app/src/main/java/thoughtbot/expandableadapter/GroupViewHolder.java new file mode 100755 index 0000000..c350c83 --- /dev/null +++ b/app/src/main/java/thoughtbot/expandableadapter/GroupViewHolder.java @@ -0,0 +1,39 @@ +package thoughtbot.expandableadapter; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.interfaces.OnGroupClickListener; + +public class GroupViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + private final OnGroupClickListener listener; + private final TextView title; + private final ImageView arrow; + + public GroupViewHolder(@NonNull final View itemView, final OnGroupClickListener listener) { + super(itemView); + this.listener = listener; + this.title = itemView.findViewById(android.R.id.text1); + this.arrow = itemView.findViewById(R.id.collapsingArrow); + this.title.setBackgroundColor(0x80_1565C0); + itemView.setOnClickListener(this); + } + + public void setTitle(@NonNull final String title) { + this.title.setText(title); + } + + @Override + public void onClick(final View v) { + if (listener != null) listener.toggleGroup(getLayoutPosition()); + } + + public void toggle(final boolean expand) { + arrow.setImageResource(expand ? R.drawable.ic_keyboard_arrow_up_24 : R.drawable.ic_keyboard_arrow_down_24); + } +} \ No newline at end of file diff --git a/app/src/main/res/anim/dialog_anim_in.xml b/app/src/main/res/anim/dialog_anim_in.xml new file mode 100644 index 0000000..1579ba6 --- /dev/null +++ b/app/src/main/res/anim/dialog_anim_in.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/anim/dialog_anim_out.xml b/app/src/main/res/anim/dialog_anim_out.xml new file mode 100644 index 0000000..a6e9683 --- /dev/null +++ b/app/src/main/res/anim/dialog_anim_out.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..5b01fc7 --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_left.xml b/app/src/main/res/anim/slide_left.xml new file mode 100644 index 0000000..8808e77 --- /dev/null +++ b/app/src/main/res/anim/slide_left.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..964d042 --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_right.xml b/app/src/main/res/anim/slide_right.xml new file mode 100644 index 0000000..7c0373b --- /dev/null +++ b/app/src/main/res/anim/slide_right.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/animator/basket_path.xml b/app/src/main/res/animator/basket_path.xml new file mode 100644 index 0000000..6935141 --- /dev/null +++ b/app/src/main/res/animator/basket_path.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/delete_mic_animation.xml b/app/src/main/res/animator/delete_mic_animation.xml new file mode 100644 index 0000000..5ec5ea3 --- /dev/null +++ b/app/src/main/res/animator/delete_mic_animation.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/emoji_picker_tab_color.xml b/app/src/main/res/color/emoji_picker_tab_color.xml new file mode 100644 index 0000000..0bf16b9 --- /dev/null +++ b/app/src/main/res/color/emoji_picker_tab_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/filter_name_color.xml b/app/src/main/res/color/filter_name_color.xml new file mode 100644 index 0000000..a3e9d39 --- /dev/null +++ b/app/src/main/res/color/filter_name_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/ic_circle_check_tint.xml b/app/src/main/res/color/ic_circle_check_tint.xml new file mode 100644 index 0000000..66f6c7e --- /dev/null +++ b/app/src/main/res/color/ic_circle_check_tint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/ic_read_button_tint.xml b/app/src/main/res/color/ic_read_button_tint.xml new file mode 100644 index 0000000..c04c3c8 --- /dev/null +++ b/app/src/main/res/color/ic_read_button_tint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/image_edit_tab_tint.xml b/app/src/main/res/color/image_edit_tab_tint.xml new file mode 100644 index 0000000..bb12e36 --- /dev/null +++ b/app/src/main/res/color/image_edit_tab_tint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/avd_mic_to_send_anim.xml b/app/src/main/res/drawable/avd_mic_to_send_anim.xml new file mode 100644 index 0000000..ddbfc8b --- /dev/null +++ b/app/src/main/res/drawable/avd_mic_to_send_anim.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avd_send_to_mic_anim.xml b/app/src/main/res/drawable/avd_send_to_mic_anim.xml new file mode 100644 index 0000000..186cf99 --- /dev/null +++ b/app/src/main/res/drawable/avd_send_to_mic_anim.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/background_grey_ripple.xml b/app/src/main/res/drawable/background_grey_ripple.xml new file mode 100644 index 0000000..1e1519b --- /dev/null +++ b/app/src/main/res/drawable/background_grey_ripple.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/barinsta_logo.png b/app/src/main/res/drawable/barinsta_logo.png new file mode 100644 index 0000000..a8e153f Binary files /dev/null and b/app/src/main/res/drawable/barinsta_logo.png differ diff --git a/app/src/main/res/drawable/bg_dm_date_header.xml b/app/src/main/res/drawable/bg_dm_date_header.xml new file mode 100644 index 0000000..253cf8d --- /dev/null +++ b/app/src/main/res/drawable/bg_dm_date_header.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_dm_time.xml b/app/src/main/res/drawable/bg_dm_time.xml new file mode 100644 index 0000000..d488993 --- /dev/null +++ b/app/src/main/res/drawable/bg_dm_time.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_indicator.xml b/app/src/main/res/drawable/bg_indicator.xml new file mode 100644 index 0000000..0e38575 --- /dev/null +++ b/app/src/main/res/drawable/bg_indicator.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bg_input.xml b/app/src/main/res/drawable/bg_input.xml new file mode 100644 index 0000000..9349a16 --- /dev/null +++ b/app/src/main/res/drawable/bg_input.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_media_share_bottom.xml b/app/src/main/res/drawable/bg_media_share_bottom.xml new file mode 100644 index 0000000..b466c66 --- /dev/null +++ b/app/src/main/res/drawable/bg_media_share_bottom.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_media_share_top_incoming.xml b/app/src/main/res/drawable/bg_media_share_top_incoming.xml new file mode 100644 index 0000000..eaeae7c --- /dev/null +++ b/app/src/main/res/drawable/bg_media_share_top_incoming.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_media_share_top_outgoing.xml b/app/src/main/res/drawable/bg_media_share_top_outgoing.xml new file mode 100644 index 0000000..e6e278a --- /dev/null +++ b/app/src/main/res/drawable/bg_media_share_top_outgoing.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_quote_line.xml b/app/src/main/res/drawable/bg_quote_line.xml new file mode 100644 index 0000000..2acd8d7 --- /dev/null +++ b/app/src/main/res/drawable/bg_quote_line.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_reply_text.xml b/app/src/main/res/drawable/bg_reply_text.xml new file mode 100644 index 0000000..ddd79ac --- /dev/null +++ b/app/src/main/res/drawable/bg_reply_text.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_rounded_corner.xml b/app/src/main/res/drawable/bg_rounded_corner.xml new file mode 100644 index 0000000..66d0a5a --- /dev/null +++ b/app/src/main/res/drawable/bg_rounded_corner.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_speech_bubble_incoming.xml b/app/src/main/res/drawable/bg_speech_bubble_incoming.xml new file mode 100644 index 0000000..61a8635 --- /dev/null +++ b/app/src/main/res/drawable/bg_speech_bubble_incoming.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_speech_bubble_outgoing.xml b/app/src/main/res/drawable/bg_speech_bubble_outgoing.xml new file mode 100644 index 0000000..eea03e7 --- /dev/null +++ b/app/src/main/res/drawable/bg_speech_bubble_outgoing.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_user_search_input.xml b/app/src/main/res/drawable/bg_user_search_input.xml new file mode 100644 index 0000000..bc48eb1 --- /dev/null +++ b/app/src/main/res/drawable/bg_user_search_input.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_account_clock_24.xml b/app/src/main/res/drawable/ic_account_clock_24.xml new file mode 100644 index 0000000..41f4ed9 --- /dev/null +++ b/app/src/main/res/drawable/ic_account_clock_24.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_account_multiple_remove_24.xml b/app/src/main/res/drawable/ic_account_multiple_remove_24.xml new file mode 100644 index 0000000..eb035e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_account_multiple_remove_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100755 index 0000000..24877ee --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_archive.xml b/app/src/main/res/drawable/ic_archive.xml new file mode 100644 index 0000000..6500188 --- /dev/null +++ b/app/src/main/res/drawable/ic_archive.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down_24.xml b/app/src/main/res/drawable/ic_arrow_drop_down_24.xml new file mode 100644 index 0000000..ce58346 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_upward_24.xml b/app/src/main/res/drawable/ic_arrow_upward_24.xml new file mode 100644 index 0000000..07b7a83 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_upward_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_check_circle_24.xml b/app/src/main/res/drawable/ic_baseline_check_circle_24.xml new file mode 100644 index 0000000..5e111ca --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_check_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_block_24.xml b/app/src/main/res/drawable/ic_block_24.xml new file mode 100644 index 0000000..9fefeec --- /dev/null +++ b/app/src/main/res/drawable/ic_block_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 0000000..3598639 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_border_style_flipped_24.xml b/app/src/main/res/drawable/ic_border_style_flipped_24.xml new file mode 100644 index 0000000..3405327 --- /dev/null +++ b/app/src/main/res/drawable/ic_border_style_flipped_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_24.xml b/app/src/main/res/drawable/ic_camera_24.xml new file mode 100644 index 0000000..ddeb49b --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..d4f0c2a --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check_24.xml b/app/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 0000000..0432fa6 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_all_24.xml b/app/src/main/res/drawable/ic_check_all_24.xml new file mode 100644 index 0000000..0e8dfc6 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_all_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_checkbox_multiple_blank.xml b/app/src/main/res/drawable/ic_checkbox_multiple_blank.xml new file mode 100644 index 0000000..7356a61 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox_multiple_blank.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_checkbox_multiple_blank_stroke.xml b/app/src/main/res/drawable/ic_checkbox_multiple_blank_stroke.xml new file mode 100644 index 0000000..7e55407 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox_multiple_blank_stroke.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_circle_check.xml b/app/src/main/res/drawable/ic_circle_check.xml new file mode 100644 index 0000000..f0708c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_class_24.xml b/app/src/main/res/drawable/ic_class_24.xml new file mode 100644 index 0000000..4a4cbea --- /dev/null +++ b/app/src/main/res/drawable/ic_class_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_clock_alert_outline_24.xml b/app/src/main/res/drawable/ic_clock_alert_outline_24.xml new file mode 100644 index 0000000..731319f --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_alert_outline_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close_24.xml b/app/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 0000000..16d6d37 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloud_download_24.xml b/app/src/main/res/drawable/ic_cloud_download_24.xml new file mode 100644 index 0000000..58832b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_download_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_dashboard_24.xml b/app/src/main/res/drawable/ic_dashboard_24.xml new file mode 100644 index 0000000..a882fd8 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..3c4030b --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100755 index 0000000..3fb2345 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_circle_24.xml b/app/src/main/res/drawable/ic_download_circle_24.xml new file mode 100644 index 0000000..2a6f025 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_circle_24.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_explore_24.xml b/app/src/main/res/drawable/ic_explore_24.xml new file mode 100644 index 0000000..0ac168a --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_face_24.xml b/app/src/main/res/drawable/ic_face_24.xml new file mode 100644 index 0000000..d63dacb --- /dev/null +++ b/app/src/main/res/drawable/ic_face_24.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_file_24.xml b/app/src/main/res/drawable/ic_file_24.xml new file mode 100644 index 0000000..f404fbf --- /dev/null +++ b/app/src/main/res/drawable/ic_file_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder_24.xml b/app/src/main/res/drawable/ic_folder_24.xml new file mode 100644 index 0000000..dc6b080 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_forward_5_24.xml b/app/src/main/res/drawable/ic_forward_5_24.xml new file mode 100644 index 0000000..4553d89 --- /dev/null +++ b/app/src/main/res/drawable/ic_forward_5_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_forward_5_24_a50.xml b/app/src/main/res/drawable/ic_forward_5_24_a50.xml new file mode 100644 index 0000000..1340c13 --- /dev/null +++ b/app/src/main/res/drawable/ic_forward_5_24_a50.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_forward_5_24_states.xml b/app/src/main/res/drawable/ic_forward_5_24_states.xml new file mode 100644 index 0000000..4c09628 --- /dev/null +++ b/app/src/main/res/drawable/ic_forward_5_24_states.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hashtag.png b/app/src/main/res/drawable/ic_hashtag.png new file mode 100644 index 0000000..786fa9b Binary files /dev/null and b/app/src/main/res/drawable/ic_hashtag.png differ diff --git a/app/src/main/res/drawable/ic_highlight_off_24.xml b/app/src/main/res/drawable/ic_highlight_off_24.xml new file mode 100644 index 0000000..6a21d0d --- /dev/null +++ b/app/src/main/res/drawable/ic_highlight_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_24.xml b/app/src/main/res/drawable/ic_home_24.xml new file mode 100644 index 0000000..3a4c7da --- /dev/null +++ b/app/src/main/res/drawable/ic_home_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_24.xml b/app/src/main/res/drawable/ic_image_24.xml new file mode 100644 index 0000000..a740230 --- /dev/null +++ b/app/src/main/res/drawable/ic_image_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_24.xml b/app/src/main/res/drawable/ic_keyboard_24.xml new file mode 100644 index 0000000..533fc15 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down_24.xml new file mode 100644 index 0000000..884bee1 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_down_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/ic_keyboard_arrow_up_24.xml new file mode 100644 index 0000000..9b15755 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_up_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_like.xml b/app/src/main/res/drawable/ic_like.xml new file mode 100644 index 0000000..52d4d9b --- /dev/null +++ b/app/src/main/res/drawable/ic_like.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_location.png b/app/src/main/res/drawable/ic_location.png new file mode 100644 index 0000000..5d4d03e Binary files /dev/null and b/app/src/main/res/drawable/ic_location.png differ diff --git a/app/src/main/res/drawable/ic_logout_24.xml b/app/src/main/res/drawable/ic_logout_24.xml new file mode 100644 index 0000000..376bc7c --- /dev/null +++ b/app/src/main/res/drawable/ic_logout_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_24.xml b/app/src/main/res/drawable/ic_message_24.xml new file mode 100644 index 0000000..a7adce5 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_horiz_24.xml b/app/src/main/res/drawable/ic_more_horiz_24.xml new file mode 100644 index 0000000..6439bcc --- /dev/null +++ b/app/src/main/res/drawable/ic_more_horiz_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_24.xml b/app/src/main/res/drawable/ic_more_vert_24.xml new file mode 100644 index 0000000..34b93ec --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_not_liked.xml b/app/src/main/res/drawable/ic_not_liked.xml new file mode 100644 index 0000000..3edfe1d --- /dev/null +++ b/app/src/main/res/drawable/ic_not_liked.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notes_24.xml b/app/src/main/res/drawable/ic_notes_24.xml new file mode 100644 index 0000000..30d7485 --- /dev/null +++ b/app/src/main/res/drawable/ic_notes_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notif.xml b/app/src/main/res/drawable/ic_notif.xml new file mode 100644 index 0000000..4a19105 --- /dev/null +++ b/app/src/main/res/drawable/ic_notif.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_in_new_24.xml b/app/src/main/res/drawable/ic_open_in_new_24.xml new file mode 100644 index 0000000..455b503 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_class_24.xml b/app/src/main/res/drawable/ic_outline_class_24.xml new file mode 100644 index 0000000..bace178 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_class_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_comments_24.xml b/app/src/main/res/drawable/ic_outline_comments_24.xml new file mode 100644 index 0000000..376f8df --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_comments_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_outline_info_24.xml b/app/src/main/res/drawable/ic_outline_info_24.xml new file mode 100644 index 0000000..35f7f5f --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_info_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_map_24.xml b/app/src/main/res/drawable/ic_outline_map_24.xml new file mode 100644 index 0000000..d0769b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_map_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_person_add_24.xml b/app/src/main/res/drawable/ic_outline_person_add_24.xml new file mode 100644 index 0000000..a2a0572 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_person_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_person_add_disabled_24.xml b/app/src/main/res/drawable/ic_outline_person_add_disabled_24.xml new file mode 100644 index 0000000..e230319 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_person_add_disabled_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_person_pin_24.xml b/app/src/main/res/drawable/ic_outline_person_pin_24.xml new file mode 100644 index 0000000..13963eb --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_person_pin_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_settings_24.xml b/app/src/main/res/drawable/ic_outline_settings_24.xml new file mode 100644 index 0000000..0d7a0a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_settings_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_star_plus_24.xml b/app/src/main/res/drawable/ic_outline_star_plus_24.xml new file mode 100644 index 0000000..2977b0e --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_star_plus_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_outline_views_24.xml b/app/src/main/res/drawable/ic_outline_views_24.xml new file mode 100644 index 0000000..f75a6af --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_views_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_24.xml b/app/src/main/res/drawable/ic_pause_24.xml new file mode 100644 index 0000000..13d6d2e --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_24.xml b/app/src/main/res/drawable/ic_person_24.xml new file mode 100644 index 0000000..6bdced2 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_photo_filter.xml b/app/src/main/res/drawable/ic_photo_filter.xml new file mode 100644 index 0000000..2735b4b --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_filter.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_24.xml b/app/src/main/res/drawable/ic_play_arrow_24.xml new file mode 100644 index 0000000..13c137a --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_24_a50.xml b/app/src/main/res/drawable/ic_play_arrow_24_a50.xml new file mode 100644 index 0000000..249e0f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_24_a50.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_circle_outline_24.xml b/app/src/main/res/drawable/ic_play_circle_outline_24.xml new file mode 100644 index 0000000..969804a --- /dev/null +++ b/app/src/main/res/drawable/ic_play_circle_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_states.xml b/app/src/main/res/drawable/ic_play_states.xml new file mode 100644 index 0000000..3f27a8c --- /dev/null +++ b/app/src/main/res/drawable/ic_play_states.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_profile_24.xml b/app/src/main/res/drawable/ic_profile_24.xml new file mode 100644 index 0000000..213244e --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_40.xml b/app/src/main/res/drawable/ic_profile_40.xml new file mode 100644 index 0000000..4dbe4c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_40.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_48.xml b/app/src/main/res/drawable/ic_profile_48.xml new file mode 100644 index 0000000..a728083 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_48.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_button_unchecked_24.xml b/app/src/main/res/drawable/ic_radio_button_unchecked_24.xml new file mode 100644 index 0000000..bcb6fc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_button_unchecked_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_24.xml b/app/src/main/res/drawable/ic_refresh_24.xml new file mode 100644 index 0000000..f2be45b --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_replay_5_24.xml b/app/src/main/res/drawable/ic_replay_5_24.xml new file mode 100644 index 0000000..b731211 --- /dev/null +++ b/app/src/main/res/drawable/ic_replay_5_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_replay_5_24_a50.xml b/app/src/main/res/drawable/ic_replay_5_24_a50.xml new file mode 100644 index 0000000..a0904ac --- /dev/null +++ b/app/src/main/res/drawable/ic_replay_5_24_a50.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_replay_5_24_states.xml b/app/src/main/res/drawable/ic_replay_5_24_states.xml new file mode 100644 index 0000000..19bad1b --- /dev/null +++ b/app/src/main/res/drawable/ic_replay_5_24_states.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_round_add_circle_24.xml b/app/src/main/res/drawable/ic_round_add_circle_24.xml new file mode 100644 index 0000000..1906afe --- /dev/null +++ b/app/src/main/res/drawable/ic_round_add_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_arrow_back_24.xml b/app/src/main/res/drawable/ic_round_arrow_back_24.xml new file mode 100644 index 0000000..26d33e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_arrow_back_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_attach_file_rot45_24.xml b/app/src/main/res/drawable/ic_round_attach_file_rot45_24.xml new file mode 100644 index 0000000..4179e25 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_attach_file_rot45_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_backspace_24.xml b/app/src/main/res/drawable/ic_round_backspace_24.xml new file mode 100644 index 0000000..fbd1b9a --- /dev/null +++ b/app/src/main/res/drawable/ic_round_backspace_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_bookmark_border_24.xml b/app/src/main/res/drawable/ic_round_bookmark_border_24.xml new file mode 100644 index 0000000..e0c532f --- /dev/null +++ b/app/src/main/res/drawable/ic_round_bookmark_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_check_circle_24.xml b/app/src/main/res/drawable/ic_round_check_circle_24.xml new file mode 100644 index 0000000..1f3ee6e --- /dev/null +++ b/app/src/main/res/drawable/ic_round_check_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_crop_24.xml b/app/src/main/res/drawable/ic_round_crop_24.xml new file mode 100644 index 0000000..c7f94e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_crop_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_drag_handle_24.xml b/app/src/main/res/drawable/ic_round_drag_handle_24.xml new file mode 100644 index 0000000..3f4f79c --- /dev/null +++ b/app/src/main/res/drawable/ic_round_drag_handle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_edit_24.xml b/app/src/main/res/drawable/ic_round_edit_24.xml new file mode 100644 index 0000000..1599eed --- /dev/null +++ b/app/src/main/res/drawable/ic_round_edit_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml b/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml new file mode 100644 index 0000000..551db0b --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_events_24.xml b/app/src/main/res/drawable/ic_round_emoji_events_24.xml new file mode 100644 index 0000000..ab52436 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_events_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_flags_24.xml b/app/src/main/res/drawable/ic_round_emoji_flags_24.xml new file mode 100644 index 0000000..5abe8b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_flags_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_food_beverage_24.xml b/app/src/main/res/drawable/ic_round_emoji_food_beverage_24.xml new file mode 100644 index 0000000..4acbfb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_food_beverage_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_nature_24.xml b/app/src/main/res/drawable/ic_round_emoji_nature_24.xml new file mode 100644 index 0000000..d238634 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_nature_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_objects_24.xml b/app/src/main/res/drawable/ic_round_emoji_objects_24.xml new file mode 100644 index 0000000..086d3cc --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_objects_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_symbols_24.xml b/app/src/main/res/drawable/ic_round_emoji_symbols_24.xml new file mode 100644 index 0000000..4380101 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_symbols_24.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_transportation_24.xml b/app/src/main/res/drawable/ic_round_emoji_transportation_24.xml new file mode 100644 index 0000000..25094d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_transportation_24.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_round_flip_camera_24.xml b/app/src/main/res/drawable/ic_round_flip_camera_24.xml new file mode 100644 index 0000000..c6326e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_flip_camera_24.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_gif_24.xml b/app/src/main/res/drawable/ic_round_gif_24.xml new file mode 100644 index 0000000..c2b5340 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_gif_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_location_on_24.xml b/app/src/main/res/drawable/ic_round_location_on_24.xml new file mode 100644 index 0000000..5dfd2de --- /dev/null +++ b/app/src/main/res/drawable/ic_round_location_on_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_mode_comment_24.xml b/app/src/main/res/drawable/ic_round_mode_comment_24.xml new file mode 100644 index 0000000..81bbaf2 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_mode_comment_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_round_pause_24.xml b/app/src/main/res/drawable/ic_round_pause_24.xml new file mode 100644 index 0000000..3492405 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_play_arrow_24.xml b/app/src/main/res/drawable/ic_round_play_arrow_24.xml new file mode 100644 index 0000000..d2ff9a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_remove_circle_24.xml b/app/src/main/res/drawable/ic_round_remove_circle_24.xml new file mode 100644 index 0000000..68d8599 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_remove_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_reply_24.xml b/app/src/main/res/drawable/ic_round_reply_24.xml new file mode 100644 index 0000000..6552e86 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_reply_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_send_24.xml b/app/src/main/res/drawable/ic_round_send_24.xml new file mode 100644 index 0000000..ae931b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_send_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_tune_24.xml b/app/src/main/res/drawable/ic_round_tune_24.xml new file mode 100644 index 0000000..eec23d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_tune_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_unknown_24.xml b/app/src/main/res/drawable/ic_round_unknown_24.xml new file mode 100644 index 0000000..c7ae78c --- /dev/null +++ b/app/src/main/res/drawable/ic_round_unknown_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_rounded_corner_24.xml b/app/src/main/res/drawable/ic_rounded_corner_24.xml new file mode 100644 index 0000000..a29f14a --- /dev/null +++ b/app/src/main/res/drawable/ic_rounded_corner_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 0000000..07b76d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_backup_restore_24.xml b/app/src/main/res/drawable/ic_settings_backup_restore_24.xml new file mode 100644 index 0000000..1772ed5 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_backup_restore_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_shutter.xml b/app/src/main/res/drawable/ic_shutter.xml new file mode 100644 index 0000000..9bb91ab --- /dev/null +++ b/app/src/main/res/drawable/ic_shutter.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shutter_focused.xml b/app/src/main/res/drawable/ic_shutter_focused.xml new file mode 100644 index 0000000..f4cdc35 --- /dev/null +++ b/app/src/main/res/drawable/ic_shutter_focused.xml @@ -0,0 +1,33 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shutter_normal.xml b/app/src/main/res/drawable/ic_shutter_normal.xml new file mode 100644 index 0000000..cd78fa7 --- /dev/null +++ b/app/src/main/res/drawable/ic_shutter_normal.xml @@ -0,0 +1,33 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shutter_pressed.xml b/app/src/main/res/drawable/ic_shutter_pressed.xml new file mode 100644 index 0000000..d92172b --- /dev/null +++ b/app/src/main/res/drawable/ic_shutter_pressed.xml @@ -0,0 +1,33 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_slider_24.xml b/app/src/main/res/drawable/ic_slider_24.xml new file mode 100644 index 0000000..388792a --- /dev/null +++ b/app/src/main/res/drawable/ic_slider_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_24.xml b/app/src/main/res/drawable/ic_star_24.xml new file mode 100644 index 0000000..6335165 --- /dev/null +++ b/app/src/main/res/drawable/ic_star_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_check_24.xml b/app/src/main/res/drawable/ic_star_check_24.xml new file mode 100644 index 0000000..b413c89 --- /dev/null +++ b/app/src/main/res/drawable/ic_star_check_24.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sticker_curved_outlines.xml b/app/src/main/res/drawable/ic_sticker_curved_outlines.xml new file mode 100644 index 0000000..ac75b93 --- /dev/null +++ b/app/src/main/res/drawable/ic_sticker_curved_outlines.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_story_list.xml b/app/src/main/res/drawable/ic_story_list.xml new file mode 100644 index 0000000..54589b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_story_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_story_viewer_list.xml b/app/src/main/res/drawable/ic_story_viewer_list.xml new file mode 100644 index 0000000..c045023 --- /dev/null +++ b/app/src/main/res/drawable/ic_story_viewer_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_submit.xml b/app/src/main/res/drawable/ic_submit.xml new file mode 100644 index 0000000..0432fa6 --- /dev/null +++ b/app/src/main/res/drawable/ic_submit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_suggested_users.xml b/app/src/main/res/drawable/ic_suggested_users.xml new file mode 100644 index 0000000..709fe2d --- /dev/null +++ b/app/src/main/res/drawable/ic_suggested_users.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_unread_indicator_24.xml b/app/src/main/res/drawable/ic_unread_indicator_24.xml new file mode 100644 index 0000000..b1256e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_unread_indicator_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_24.xml b/app/src/main/res/drawable/ic_video_24.xml new file mode 100644 index 0000000..18b9cfc --- /dev/null +++ b/app/src/main/res/drawable/ic_video_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_agenda_24.xml b/app/src/main/res/drawable/ic_view_agenda_24.xml new file mode 100644 index 0000000..238ff4d --- /dev/null +++ b/app/src/main/res/drawable/ic_view_agenda_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_grid_24.xml b/app/src/main/res/drawable/ic_view_grid_24.xml new file mode 100644 index 0000000..35160ee --- /dev/null +++ b/app/src/main/res/drawable/ic_view_grid_24.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_volume_off_24.xml b/app/src/main/res/drawable/ic_volume_off_24.xml new file mode 100644 index 0000000..aaf49e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off_24_a50.xml b/app/src/main/res/drawable/ic_volume_off_24_a50.xml new file mode 100644 index 0000000..5bbfbe9 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_24_a50.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off_24_states.xml b/app/src/main/res/drawable/ic_volume_off_24_states.xml new file mode 100644 index 0000000..71b5d07 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_24_states.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_volume_up_24.xml b/app/src/main/res/drawable/ic_volume_up_24.xml new file mode 100644 index 0000000..0db3469 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_24_a50.xml b/app/src/main/res/drawable/ic_volume_up_24_a50.xml new file mode 100644 index 0000000..9c518f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_24_a50.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_24_states.xml b/app/src/main/res/drawable/ic_volume_up_24_states.xml new file mode 100644 index 0000000..3a947b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_24_states.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..506d058 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/launch.xml b/app/src/main/res/drawable/launch.xml new file mode 100644 index 0000000..c34eafa --- /dev/null +++ b/app/src/main/res/drawable/launch.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/launch_dark.xml b/app/src/main/res/drawable/launch_dark.xml new file mode 100644 index 0000000..d436940 --- /dev/null +++ b/app/src/main/res/drawable/launch_dark.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/launch_screen.xml b/app/src/main/res/drawable/launch_screen.xml new file mode 100644 index 0000000..dbca593 --- /dev/null +++ b/app/src/main/res/drawable/launch_screen.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml new file mode 100755 index 0000000..aa15a43 --- /dev/null +++ b/app/src/main/res/drawable/lock.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/popup_background_exoplayer.xml b/app/src/main/res/drawable/popup_background_exoplayer.xml new file mode 100644 index 0000000..ece4d1b --- /dev/null +++ b/app/src/main/res/drawable/popup_background_exoplayer.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pref_list_divider_material.xml b/app/src/main/res/drawable/pref_list_divider_material.xml new file mode 100644 index 0000000..2228686 --- /dev/null +++ b/app/src/main/res/drawable/pref_list_divider_material.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recv_basket_animated.xml b/app/src/main/res/drawable/recv_basket_animated.xml new file mode 100644 index 0000000..b4e2d0f --- /dev/null +++ b/app/src/main/res/drawable/recv_basket_animated.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recv_ic_arrow.xml b/app/src/main/res/drawable/recv_ic_arrow.xml new file mode 100644 index 0000000..c87635d --- /dev/null +++ b/app/src/main/res/drawable/recv_ic_arrow.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recv_ic_delete.xml b/app/src/main/res/drawable/recv_ic_delete.xml new file mode 100644 index 0000000..dd66ac2 --- /dev/null +++ b/app/src/main/res/drawable/recv_ic_delete.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recv_ic_mic.xml b/app/src/main/res/drawable/recv_ic_mic.xml new file mode 100644 index 0000000..54f6e9b --- /dev/null +++ b/app/src/main/res/drawable/recv_ic_mic.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounder_corner_bg.xml b/app/src/main/res/drawable/rounder_corner_bg.xml new file mode 100644 index 0000000..d87ca06 --- /dev/null +++ b/app/src/main/res/drawable/rounder_corner_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounder_corner_semi_black_bg.xml b/app/src/main/res/drawable/rounder_corner_semi_black_bg.xml new file mode 100644 index 0000000..70b068a --- /dev/null +++ b/app/src/main/res/drawable/rounder_corner_semi_black_bg.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_oval_light.xml b/app/src/main/res/drawable/shape_oval_light.xml new file mode 100644 index 0000000..194d0b3 --- /dev/null +++ b/app/src/main/res/drawable/shape_oval_light.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sl_favourite_24.xml b/app/src/main/res/drawable/sl_favourite_24.xml new file mode 100644 index 0000000..1456a6e --- /dev/null +++ b/app/src/main/res/drawable/sl_favourite_24.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/speed_text_color_states.xml b/app/src/main/res/drawable/speed_text_color_states.xml new file mode 100644 index 0000000..2e62e4f --- /dev/null +++ b/app/src/main/res/drawable/speed_text_color_states.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/verified.png b/app/src/main/res/drawable/verified.png new file mode 100644 index 0000000..434c0ff Binary files /dev/null and b/app/src/main/res/drawable/verified.png differ diff --git a/app/src/main/res/layout-land/activity_camera.xml b/app/src/main/res/layout-land/activity_camera.xml new file mode 100644 index 0000000..4499c7e --- /dev/null +++ b/app/src/main/res/layout-land/activity_camera.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_camera.xml b/app/src/main/res/layout/activity_camera.xml new file mode 100644 index 0000000..35de271 --- /dev/null +++ b/app/src/main/res/layout/activity_camera.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_crash_error.xml b/app/src/main/res/layout/activity_crash_error.xml new file mode 100755 index 0000000..dafd76d --- /dev/null +++ b/app/src/main/res/layout/activity_crash_error.xml @@ -0,0 +1,51 @@ + + + + + + + +