diff --git a/.gitignore b/.gitignore index 7d3de44..71032d1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ local.properties app/google-services.json app/release +sentry.properties diff --git a/.idea/.gitignore b/.idea/.gitignore index 26d3352..8f00030 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -1,3 +1,5 @@ # Default ignored files /shelf/ /workspace.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 4bec4ea..1bec35e 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,117 +1,10 @@ - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index a55e7a1..6e6eec1 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..e320f99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index e76fb07..ad9cccb 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,14 +4,30 @@ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 44ca2d9..bb270b8 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,34 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml index 8978d23..cf5d4ff 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,8 @@ + + + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 49c2e3e..28d49b3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ ![TroploPrivateUploader Banner](https://i.troplo.com/i/9ea16d8ab178.png) -# TPUvNATIVE -The native Android client for TPU. +# Flowinity Kotlin (formerly PrivateUploader) +The native Android client for Flowinity. diff --git a/app/benchmark/.gitignore b/app/benchmark/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/benchmark/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/benchmark/build.gradle.kts b/app/benchmark/build.gradle.kts new file mode 100644 index 0000000..474144b --- /dev/null +++ b/app/benchmark/build.gradle.kts @@ -0,0 +1,53 @@ +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + alias(libs.plugins.androidTest) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = "com.troplo.benchmark" + compileSdk = 33 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + minSdk = 26 + targetSdk = 33 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // This benchmark buildType is used for benchmarking, and should function like your + // release build (for example, with minification on). It"s signed with a debug key + // for easy local/CI testing. + create("benchmark") { + isDebuggable = true + signingConfig = getByName("debug").signingConfig + matchingFallbacks += listOf("release") + } + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.espresso.core) + implementation(libs.uiautomator) + implementation(libs.benchmark.macro.junit4) +} + +androidComponents { + beforeVariants(selector().all()) { + it.enable = it.buildType == "benchmark" + } +} \ No newline at end of file diff --git a/app/benchmark/src/main/AndroidManifest.xml b/app/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000..227314e --- /dev/null +++ b/app/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/benchmark/src/main/java/com/troplo/benchmark/ExampleStartupBenchmark.kt b/app/benchmark/src/main/java/com/troplo/benchmark/ExampleStartupBenchmark.kt new file mode 100644 index 0000000..5a59c62 --- /dev/null +++ b/app/benchmark/src/main/java/com/troplo/benchmark/ExampleStartupBenchmark.kt @@ -0,0 +1,39 @@ +package com.troplo.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This is an example startup benchmark. + * + * It navigates to the device's home screen, and launches the default activity. + * + * Before running this benchmark: + * 1) switch your app's active build variant in the Studio (affects Studio runs only) + * 2) add `` to your app's manifest, within the `` tag + * + * Run this benchmark from Studio to see startup measurements, and captured system traces + * for investigating your app's performance. + */ +@RunWith(AndroidJUnit4::class) +class ExampleStartupBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startup() = benchmarkRule.measureRepeated( + packageName = "com.troplo.privateuploader", + metrics = listOf(StartupTimingMetric()), + iterations = 5, + startupMode = StartupMode.COLD + ) { + pressHome() + startActivityAndWait() + } +} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e1b618b..1271f27 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,25 +1,42 @@ +import java.text.DateFormat + @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid) + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" id("io.sentry.android.gradle") version "3.11.1" id("com.google.gms.google-services") +// id("com.apollographql.apollo3") version "3.8.6" + id("se.patrikerdes.use-latest-versions") version "0.2.18" + id("com.github.ben-manes.versions") version "0.41.0" +} + +//apollo { +// service("service") { +// packageName.set("com.troplo.privateuploader") +// } +//} + +sentry { + autoUploadProguardMapping + autoUploadNativeSymbols + autoUploadSourceContext } android { namespace = "com.troplo.privateuploader" - compileSdk = 34 + compileSdk = 36 defaultConfig { applicationId = "com.troplo.privateuploader" minSdk = 28 - targetSdk = 34 - versionCode = 7 - versionName = "1.0.7" + targetSdk = 36 + versionCode = 15 + versionName = "1.0.15" multiDexEnabled = true - buildConfigField("String", "SERVER_URL", "\"https://privateuploader.com\"") - buildConfigField("String", "BUILD_TIME", "\"${System.currentTimeMillis()}\"") - buildConfigField("Integer", "BETA_VERSION", "6") + buildConfigField("String", "SERVER_URL", "\"https://flowinity.com\"") + buildConfigField("String", "BUILD_TIME", "\"${DateFormat.getDateTimeInstance().format(System.currentTimeMillis())}\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -29,8 +46,9 @@ android { buildTypes { release { - isMinifyEnabled = false - buildConfigField("String", "SERVER_URL", "\"https://privateuploader.com\"") + isMinifyEnabled = true + isShrinkResources = true + buildConfigField("String", "SERVER_URL", "\"https://flowinity.com\"") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -40,6 +58,12 @@ android { debug { buildConfigField("String", "SERVER_URL", "\"http://192.168.0.12:34582\"") } + create("benchmark") { + initWith(buildTypes.getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + isDebuggable = false + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -63,15 +87,16 @@ android { } dependencies { + implementation("com.github.skydoves:landscapist-glide:2.5.2") +// implementation("com.apollographql.apollo3:apollo-runtime:3.8.6") // Firebase - implementation(platform("com.google.firebase:firebase-bom:32.1.1")) - implementation("com.google.firebase:firebase-messaging-ktx:23.1.2") - implementation("com.google.firebase:firebase-analytics-ktx:21.3.0") + implementation(platform("com.google.firebase:firebase-bom:33.5.1")) + implementation("com.google.firebase:firebase-messaging-ktx:23.2.0") + implementation("com.google.firebase:firebase-analytics-ktx:22.1.2") // TPU - implementation("androidx.core:core-splashscreen:1.0.1") - implementation("com.github.wax911:android-emojify:1.7.1") - implementation("androidx.activity:activity:1.7.2") + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.activity) implementation("com.google.accompanist:accompanist-permissions:0.31.4-beta") implementation("io.mhssn:colorpicker:1.0.0") implementation("com.google.accompanist:accompanist-insets:0.22.0-rc") @@ -80,33 +105,34 @@ dependencies { implementation("io.noties.markwon:ext-tables:4.6.2") implementation("io.noties.markwon:html:4.6.2") implementation("io.noties.markwon:linkify:4.6.2") - implementation("io.sentry:sentry-android:6.23.0") - implementation("io.sentry:sentry-compose-android:6.23.0") + implementation("io.sentry:sentry-okhttp:8.20.0") + implementation("io.sentry:sentry-android:8.20.0") + implementation("io.sentry:sentry-compose-android:8.20.0") implementation("androidx.compose.runtime:runtime-tracing:1.0.0-alpha03") - implementation("io.coil-kt:coil-gif:2.4.0") - implementation("io.coil-kt:coil-compose:2.4.0") implementation("com.github.X1nto:OverlappingPanelsCompose:1.2.0") - implementation("io.coil-kt:coil:2.3.0") implementation("com.github.jeziellago:compose-markdown:0.3.3") - implementation("com.github.bumptech.glide:compose:1.0.0-alpha.1") - implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.squareup.retrofit2:converter-moshi:2.9.0") - implementation("com.squareup.moshi:moshi:1.12.0") - implementation("com.squareup.moshi:moshi-kotlin:1.12.0") - implementation("com.github.bumptech.glide:glide:4.16.0-SNAPSHOT") + implementation("com.github.bumptech.glide:compose:1.0.0-beta01") + implementation("com.squareup.retrofit2:retrofit:3.0.0") + implementation("com.squareup.retrofit2:converter-moshi:3.0.0") + implementation("com.squareup.moshi:moshi:1.15.2") + implementation("com.squareup.moshi:moshi-kotlin:1.15.2") + implementation("com.github.bumptech.glide:glide:5.0.4") implementation("com.squareup.okhttp3:logging-interceptor:4.9.1") + implementation("io.coil-kt:coil:2.7.0") + implementation("io.coil-kt:coil-compose:2.7.0") + implementation("io.coil-kt:coil-gif:2.7.0") implementation("androidx.core:core-ktx:1.10.1") - implementation("com.google.code.gson:gson:2.10.1") - implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation("com.google.code.gson:gson:2.13.1") + implementation("com.squareup.retrofit2:converter-gson:3.0.0") implementation("io.socket:socket.io-client:2.1.0") { exclude("org.json", "json") } // Android - implementation("androidx.navigation:navigation-compose:2.6.0") + implementation("androidx.navigation:navigation-compose:2.9.3") // Material Design 3 - implementation("androidx.compose.material3:material3:1.2.0-alpha03") + implementation("androidx.compose.material3:material3:1.3.2") // For SwipeableState implementation("androidx.compose.material:material:1.4.3") // or only import the main APIs for the underlying toolkit systems, @@ -146,4 +172,8 @@ dependencies { androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) +} + +configurations.all { + exclude(group = "io.sentry", module = "sentry-android-okhttp") } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..c0c4397 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,25 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +-dontwarn kotlinx.serialization.DeserializationStrategy +-dontwarn kotlinx.serialization.KSerializer +-dontwarn kotlinx.serialization.Serializable +-dontwarn kotlinx.serialization.builtins.BuiltinSerializersKt +-dontwarn kotlinx.serialization.internal.ArrayListSerializer +-dontwarn kotlinx.serialization.internal.GeneratedSerializer +-dontwarn kotlinx.serialization.internal.PluginGeneratedSerialDescriptor +-dontwarn kotlinx.serialization.internal.StringSerializer +-dontwarn kotlinx.serialization.json.Json +-dontwarn kotlinx.serialization.json.JsonBuilder +-dontwarn kotlinx.serialization.json.JsonKt +-dontwarn kotlinx.serialization.json.JvmStreamsKt +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +-keep class * { + @com.google.gson.annotations.SerializedName ; +} +-keepnames class com.fasterxml.jackson.databind.** { *; } +-dontwarn com.fasterxml.jackson.databind.** \ No newline at end of file diff --git a/app/src/main/graphql/schema.sdl b/app/src/main/graphql/schema.sdl new file mode 100644 index 0000000..b2c66f8 --- /dev/null +++ b/app/src/main/graphql/schema.sdl @@ -0,0 +1,1765 @@ +type Query { + currentUser: User + user(input: UserProfileInput!): PartialUserPublic + trackedUsers: [PartialUserFriend!]! + trackedUserIds: [Float!]! + coreState: CoreState! + setupStep: Int! + experiments: [ExperimentType!]! + weather: Weather! + collections(input: UserCollectionsInput): PaginatedCollectionResponse + collection(input: CollectionInput!): Collection + domains: [Domain!]! + gallery(input: GalleryInput!): PaginatedUploadResponse! + chats(input: ChatsInput): [Chat!]! + chat(input: ChatInput!): Chat! + availableChatPermissions: [ChatPermission!]! + lookupBotPrefix(input: LookupPrefixInput!): Prefix! + workspaces: [Workspace!]! + note(input: NoteInput!): Note + friends(input: FriendsInput): [Friend!]! + messages(input: InfiniteMessagesInput!): [Message!]! + messagesPaged(input: PagedMessagesInput!): PaginatedMessageResponse! + autoCollects(input: UserCollectionsInput!): PaginatedCollectionResponse! + blockedUsers: [BlockedUser!]! + chatInvite(input: InviteInput!): ChatInvite + mailboxes: [ListResponse!]! + unreadMail: Int! + getMail(input: GetMailInput!): JSON! + oauthApps: [OauthApp!]! + oauthApp(input: MyAppInput!): OauthApp! + oauthAppConsent(input: MyAppInput!): OauthConsentApp! + getAuthorizedApps: [OauthApp!]! + userEmoji: [ChatEmoji!]! + chatAuditLog(input: AuditLogInput!): PaginatedChatAuditLogResponse! +} + +type User { + id: Int! + createdAt: Date! + updatedAt: Date! + username: String! + email: String! + description: String + administrator: Boolean! + darkTheme: Boolean! + emailVerified: Boolean! + banned: Boolean! + inviteId: Float + discordPrecache: Boolean! + avatar: String + subdomainId: Float @deprecated(reason: "Subdomains are no longer available as of TPUv2/NEXT.") + domainId: Float! + totpEnable: Boolean! + + """How much the user has uploaded in bytes.""" + quota: Float! + uploadNameHidden: Boolean! @deprecated(reason: "Hidden upload usernames are no longer available as of TPUv2/NEXT.") + invisibleURLs: Boolean! @deprecated(reason: "Invisible URLs are no longer available as of TPUv2/NEXT.") + moderator: Boolean! + + """ + Subscriptions are no longer used as they were in TPUv1, and are now used to store metadata for permanent Gold subscriptions. + """ + subscriptionId: Float + fakePath: String @deprecated(reason: "Fake paths are no longer available as of TPUv2/NEXT.") + themeId: Float! @deprecated(reason: "Replaced with `themeEngine`, used in legacy clients such as legacy.privateuploader.com.") + itemsPerPage: Float! + + """UserV2 banner.""" + banner: String + + """Ability to login with more then 1 password with different scopes.""" + alternatePasswords: [AlternatePassword!] + + """User status/presence shown to other users.""" + status: UserStatus! + + """ + User status/presence that has `invisible` and is shown to the current user. + """ + storedStatus: UserStoredStatus! + weatherUnit: String! + themeEngine: ThemeEngine + insights: UserInsights! + profileLayout: ProfileLayout! + + """Collections that are excluded from the Collections filter in Gallery.""" + excludedCollections: [Float!] + language: String! + publicProfile: Boolean! + + """ + How much the user has donated to PrivateUploader. (Likely unused in unofficial instances.) + """ + xp: Float! + privacyPolicyAccepted: Boolean + + """The user's name color in Communications.""" + nameColor: String + + """Whether the user is a bot user.""" + bot: Boolean! + plan: Plan + domain: Domain + subscription: TPUSubscription + experiments: [Experiment!] + badges: [Badge!]! + autoCollectRules: [AutoCollectRule!]! + + """ + The user's scopes assigned to the API key used. In format like `user.view,user.modify` which belong to `Scope`. + """ + scopes: String + stats: Stats + oauthAppId: String + nickname: [FriendNickname!] + integrations: [Integration!]! + notifications: [Notification!]! + sessions: [Session!]! + + """How many AutoCollect approvals the user needs to approve/reject.""" + pendingAutoCollects: Float + friends: [Friend!]! + friend: FriendStatus! +} + +scalar Date + +type AlternatePassword { + scopes: String! + totp: Boolean! + name: String! +} + +"""User status/presence shown to other users.""" +enum UserStatus { + ONLINE + IDLE + OFFLINE + BUSY + UNKNOWN +} + +""" +User status/presence that has `invisible` and is shown to the current user. +""" +enum UserStoredStatus { + ONLINE + IDLE + BUSY + INVISIBLE +} + +type ThemeEngine { + theme: ThemeEngineThemes! + fluidGradient: Boolean! + gradientOffset: String! + defaults: ThemeEngineThemes + version: Float! + deviceSync: Boolean! + showOnProfile: Boolean! + baseTheme: String! + customCSS: String +} + +type ThemeEngineThemes { + dark: ThemeEngineTheme! + light: ThemeEngineTheme! + amoled: ThemeEngineTheme! +} + +type ThemeEngineTheme { + colors: ThemeEngineColors! + dark: Boolean +} + +type ThemeEngineColors { + primary: String! + logo1: String! + logo2: String! + secondary: String! + accent: String! + error: String! + info: String! + success: String! + warning: String! + card: String! + toolbar: String! + sheet: String! + text: String! + dark: String! + gold: String! + background: String! + background2: String! +} + +"""Insights privacy preference.""" +enum UserInsights { + EVERYONE + FRIENDS + NOBODY +} + +type ProfileLayout { + layout: ProfileLayoutObject! + config: ProfileLayoutConfig! + version: Float! +} + +type ProfileLayoutObject { + columns: [ProfileLayoutColumn!]! +} + +type ProfileLayoutColumn { + rows: [ProfileLayoutComponent!]! +} + +type ProfileLayoutComponent { + name: String! + id: String! + props: ProfileLayoutProps +} + +type ProfileLayoutProps { + height: Float + children: [ProfileLayoutComponent!] + friendsOnly: Boolean + display: Float + type: String + links: [ProfileLayoutPropLink!] +} + +type ProfileLayoutPropLink { + name: String! + url: String! + color: String! +} + +type ProfileLayoutConfig { + containerMargin: Float + showStatsSidebar: Boolean! +} + +type Plan { + id: Int! + name: String! + quotaMax: Float! + price: Float! @deprecated(reason: "Plans are unused in TPUv2+.") + features: String @deprecated(reason: "Plans are unused in TPUv2+.") + color: String + internalName: String! + purchasable: Boolean! @deprecated(reason: "Plans are unused in TPUv2+.") + internalFeatures: String @deprecated(reason: "Plans are unused in TPUv2+.") + icon: String! +} + +type Domain { + id: Float! + domain: String! + userId: Float! + DNSProvisioned: Boolean! @deprecated(reason: "Use `active` instead.") + active: Boolean! + zone: String @deprecated(reason: "Cloudflare integration was removed in TPUv2.") + advanced: Float @deprecated(reason: "Cloudflare integration was removed in TPUv2.") + subdomains: Boolean! @deprecated(reason: "Subdomains were removed in TPUv2.") + subdomainsCreate: Boolean! @deprecated(reason: "Subdomains were removed in TPUv2.") + customUserEligibility: [Int!] @deprecated(reason: "Granular user control was removed in TPUv2.") + restricted: String! @deprecated(reason: "Granular user control was removed in TPUv2.") + user: PartialUserBase! + + """Only populated in some admin contexts""" + users: [PartialUserBase!] +} + +type PartialUserBase { + username: String! + id: Float! + createdAt: Date! + administrator: Boolean! + moderator: Boolean! + avatar: String + bot: Boolean! +} + +type TPUSubscription { + planId: Float! + userId: Float! + price: Float! + cancelled: Boolean! + paymentId: Float! + expiredAt: DateTimeISO! + cancelledAt: DateTimeISO! + metadata: SubscriptionMetadata! + user: PartialUserBase! +} + +""" +A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.This scalar is serialized to a string in ISO 8601 format and parsed from a string in ISO 8601 format. +""" +scalar DateTimeISO + +type SubscriptionMetadata { + hours: Float! +} + +type Experiment { + key: String! + value: String! + userId: Float! + user: PartialUserBase! +} + +type Badge { + id: Int! + name: String! + description: String + tooltip: String + image: String + icon: String + color: String + unlocked: Boolean! + priority: Float + plan: Plan! + users: [PartialUserBase!]! +} + +type AutoCollectRule { + id: Int! + name: String! + enabled: Boolean! + collectionId: Float! + requireApproval: Boolean! + rules: [AutoCollectParentRule!]! +} + +type AutoCollectParentRule { + id: Float! + rules: [SubRule!]! +} + +type SubRule { + id: Float! + type: String! + value: String! + operator: String! +} + +type Stats { + users: Float! + announcements: Float! + usage: Float! + collections: Float! + collectionItems: Float! + uploadGraph: DataLabelsGraph! + messageGraph: DataLabelsGraph! + pulseGraph: DataLabelsGraph! + uploads: Float! + pulse: Float! + pulses: Float! + docs: Float! +} + +type DataLabelsGraph { + data: [Float!]! + labels: [String!]! +} + +type FriendNickname { + id: Int! + userId: Float! + createdAt: Date! + updatedAt: Date! + friendId: Float! + nickname: String! + user: PartialUserBase! + friend: PartialUserBase! +} + +type Integration { + id: Int! + userId: Float! + type: String! + expiresAt: DateTimeISO + createdAt: Date! + updatedAt: Date! + providerUserId: Float + providerUsername: String + providerUserCache: JSON + error: String + user: PartialUserBase! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + +type Notification { + id: Int! + message: String! + userId: Float! + dismissed: Boolean! + route: String + createdAt: Date! + updatedAt: Date! + user: PartialUserBase +} + +type Session { + id: Int! + userId: Float! + scopes: String! + type: String! + expiredAt: DateTimeISO + name: String + info: SessionInfo + oauthAppId: String +} + +type SessionInfo { + accessedFrom: [AccessedFrom!]! +} + +type AccessedFrom { + ip: String! + userAgent: String + isp: String + location: String + date: String! + asn: Float +} + +type Friend { + id: Int! + createdAt: Date! + updatedAt: Date! + status: FriendStatus! + userId: Int! + friendId: Int! + user: PartialUserFriend! + otherUser: PartialUserFriend! +} + +"""Friend request status.""" +enum FriendStatus { + NONE + INCOMING + OUTGOING + ACCEPTED +} + +type PartialUserFriend { + username: String! + id: Float! + createdAt: Date! + administrator: Boolean! + moderator: Boolean! + avatar: String + bot: Boolean! + status: UserStatus! + nameColor: String + nickname: FriendNickname + blocked: Boolean +} + +type PartialUserPublic { + bot: Boolean! + username: String! + id: Float! + createdAt: Date! + administrator: Boolean! + moderator: Boolean! + avatar: String + badges: [Badge!]! + banned: Boolean! + banner: String + description: String + friend: FriendStatus + friends: [Friend!] + insights: UserInsights! + plan: Plan! + platforms: [Platform!] + profileLayout: ProfileLayout + publicProfile: Boolean! + quota: Float! + stats: Stats + themeEngine: ThemeEngine + xp: Float + notifications: [Notification!]! + integrations: [Integration!]! + domain: [Domain!]! + subscription: [TPUSubscription!]! + autoCollectRules: AutoCollectRule! +} + +type Platform { + platform: String! + id: String! + lastSeen: String! + status: String! +} + +input UserProfileInput { + id: Float + username: String +} + +type CoreState { + connection: Connection! + name: String! + + """Whether the app is running in production mode.""" + release: String! + hostname: String! + hostnameWithProtocol: String! + announcements: [Announcement!]! + stats: CoreStats! + maintenance: Maintenance! + registrations: Boolean! + + """ + Whether the TPU instance is the officially run instance on privateuploader.com. This can be enabled on any instance but can enable unwanted features. + """ + officialInstance: Boolean! + providers: Providers! + + """Workspaces Note ID for the Terms of Service.""" + termsNoteId: String + + """Workspaces Note ID for the Privacy Policy.""" + privacyNoteId: String + + """List of enabled features for TPU instance""" + features: Features! + inviteAFriend: Boolean! + + """ + List of domains that are pre-trusted for user-generated hyperlinks such as Communications messages which don't require a confirmation to proceed. + """ + preTrustedDomains: [String!]! + hostnames: [String!]! + _redis: String! + server: String! + finishedSetup: Boolean! + domain: String! + + """Uptime of the TPU Server in seconds.""" + uptime: Float! + + """Uptime of the system in seconds.""" + uptimeSys: Float! + commitVersion: String! + weather: Weather! +} + +type Connection { + ip: String! + whitelist: Boolean! @deprecated(reason: "No longer used in v4.") +} + +type Announcement { + id: Int! + userId: Float! + content: String! + type: String + createdAt: Date + updatedAt: Date + user: PartialUserBase! +} + +type CoreStats { + users: Float! + announcements: Float! + usage: Float! + collections: Float! + collectionItems: Float! + uploadGraph: DataLabelsGraph! + messageGraph: DataLabelsGraph! + pulseGraph: DataLabelsGraph! + uploads: Float! + pulse: Float! + pulses: Float! + docs: Float! + invites: Float! + inviteMilestone: Float! + messages: Float! + chats: Float! + hours: [String!] +} + +type Maintenance { + enabled: Boolean! + message: String + statusPage: String +} + +type Providers { + anilist: Boolean! + lastfm: Boolean! + mal: Boolean! +} + +type Features { + communications: Boolean! + collections: Boolean! + autoCollects: Boolean! + workspaces: Boolean! + insights: Boolean! +} + +type Weather { + icon: String + temp: Float + feels_like: Float + temp_min: Float + temp_max: Float + pressure: Float + humidity: Float + wind_speed: Float + wind_deg: Float + clouds: Float + visibility: Float + error: Boolean + cached: Boolean + description: String + main: String + location: String +} + +type ExperimentType { + id: String! + value: Int! + description: String + createdAt: Date + refresh: Boolean +} + +type PaginatedCollectionResponse { + items: [Collection!]! + pager: Pager! +} + +type Collection { + id: Int! + name: String! + image: String + userId: Float! + shareLink: String + user: PartialUserBase! + preview: CollectionItem + users: [CollectionUser!]! + recipient: CollectionUser + autoCollectApprovals: [AutoCollectApproval!]! + shared: Boolean + itemCount: Int + attachments: [Upload!]! + permissionsMetadata: PermissionsMetadata! +} + +type CollectionItem { + id: Int! + collectionId: Float! + attachmentId: Float! + userId: Float! + + """ + Used to prevent duplicates by forming `uploadId-collectionId`. Can be null for items created before October 2022. + """ + identifier: String + pinned: Boolean! + user: PartialUserBase! + collection: Collection! + attachment: Upload! +} + +type Upload { + id: Int! + createdAt: Date! + updatedAt: Date! + attachment: String! + userId: Float! + name: String + originalFilename: String + type: String! + urlRedirect: String @deprecated(reason: "URL redirects were removed in TPUv2/NEXT.") + fileSize: Float! + + """ + Non-deletable items are used for profile pictures, banners, etc and are not visible in the Gallery page. + """ + deletable: Boolean! + + """This is used for OCR scanned text from images.""" + textMetadata: String + user: PartialUserBase + item: CollectionItem + collections: [Collection!]! + items: [CollectionItem!]! + starred: Star + autoCollectApproval: AutoCollectApproval +} + +type Star { + id: Int! + userId: Float! + attachmentId: Float! + user: PartialUserBase! + attachment: Upload! +} + +type AutoCollectApproval { + id: Int! + autoCollectRuleId: Float! + uploadId: Float! + collectionId: Float! + userId: Float! + approved: Boolean! + user: PartialUserBase + collection: Collection + autoCollectRule: AutoCollectRule + attachment: Upload +} + +type CollectionUser { + id: Int! + createdAt: Date! + updatedAt: Date! + collectionId: Float! + read: Boolean! + write: Boolean! + configure: Boolean! + accepted: Boolean! + recipientId: Float + senderId: Float + identifier: String + collection: Collection + user: PartialUserBase + sender: PartialUserBase +} + +type PermissionsMetadata { + write: Boolean! + read: Boolean! + configure: Boolean! +} + +type Pager { + totalItems: Int! + currentPage: Int! + pageSize: Int! + totalPages: Int! + startPage: Int! + endPage: Int! + startIndex: Int! + endIndex: Int! + pages: [Float!]! +} + +input UserCollectionsInput { + filter: [CollectionFilter!]! = [ALL] + search: String + limit: Float! = 24 + page: Float! = 1 +} + +"""The type of collection""" +enum CollectionFilter { + ALL + WRITE + READ + CONFIGURE + SHARED + OWNED +} + +input CollectionInput { + id: Int + shareLink: String +} + +type PaginatedUploadResponse { + items: [Upload!]! + pager: Pager! +} + +input GalleryInput { + search: String = "" + page: Int = 1 + limit: Int + filters: [GalleryFilter!] = [ALL] + sort: GallerySort = CREATED_AT + order: GalleryOrder = DESC + type: GalleryType = PERSONAL + + """Requires Type to be COLLECTION""" + collectionId: Int + + """Requires Type to be COLLECTION""" + shareLink: String + advanced: [SearchModeInput!] +} + +"""The filter to apply to the gallery request""" +enum GalleryFilter { + ALL + OWNED + SHARED + NO_COLLECTION + IMAGES + VIDEOS + GIFS + AUDIO + TEXT + OTHER + PASTE + INCLUDE_METADATA + INCLUDE_DELETABLE +} + +"""The sort to apply to the gallery request""" +enum GallerySort { + CREATED_AT + UPDATED_AT + NAME + SIZE +} + +"""The order to apply to the gallery request""" +enum GalleryOrder { + ASC + DESC +} + +""" +The type of gallery request, for example if it's the personal gallery page, or a Collection +""" +enum GalleryType { + PERSONAL + STARRED + COLLECTION + AUTO_COLLECT +} + +input SearchModeInput { + mode: GallerySearchMode! + value: String +} + +"""The advanced search mode.""" +enum GallerySearchMode { + AFTER + DURING + USER + SIZE + NAME + META + TYPE + COLLECTION + BEFORE + ORDER +} + +type Chat { + id: Int! + type: String! + name: String! + + """ + Null if the chat is owned by a Colubrina legacy user, or the account was deleted. + """ + userId: Float + icon: String + createdAt: DateTimeISO! + updatedAt: DateTimeISO! + + """This is used if the chat is owned by a Colubrina legacy user.""" + legacyUserId: Float @deprecated(reason: "Use userId instead.") + + """ + Null if the chat is owned by a Colubrina legacy user, or the account was deleted. + """ + user: PartialUserBase + + """This is used if the chat is owned by a Colubrina legacy user.""" + legacyUser: PartialUserBase @deprecated(reason: "Use user instead.") + association: ChatAssociation + users: [ChatAssociation!]! + description: String + background: String + unread: Int + _redisSortDate: String + ranks: [ChatRank!]! + messages: [Message!]! + + """ + Array is empty if you don't have the `OVERVIEW` permission in the chat. + """ + invites: [ChatInvite!]! + emoji: [ChatEmoji!] + recipient: PartialUserBase +} + +type ChatAssociation { + id: Int! + chatId: Float! + userId: Float + rank: String! @deprecated(reason: "`ChatRank` has replaced legacy rank for granular permission control.") + lastRead: Float + createdAt: Date! + notifications: String! + + """Used for legacy Colubrina accounts.""" + legacyUserId: Float @deprecated(reason: "Use `userId` instead.") + + """ + Only true/false for current user, null for other ChatAssociations. This determines whether the chat is visible in the sidebar (open or closed). + """ + hidden: Boolean + inviteUsed: String + invite: ChatInvite + + """Used for user virtual which falls back to a Colubrina account.""" + tpuUser: PartialUserBase + + """Used for legacy Colubrina accounts.""" + legacyUser: PartialUserBase @deprecated(reason: "Use `user` instead.") + user: PartialUserBase + ranks: [ChatRank!]! + ranksMap: [String!]! + permissions: [String!]! +} + +type ChatInvite { + id: String! + userId: Int! + chatId: Int! + + """ + Automatically assigns rank to user when joining. If unset the backend will set the `managed` Members role. + """ + rankId: String + createdAt: Date! + updatedAt: Date! + expiredAt: Date + invalidated: Boolean! + user: PartialUserBase! + chat: Chat! + rank: ChatRank +} + +type ChatRank { + id: String! + color: String + name: String! + userId: Int! + createdAt: Date + chatId: Int! + updatedAt: Date + managed: Boolean! + index: Int! + permissions: [ChatPermission!]! + permissionsMap: [String!]! + associations: [ChatAssociation!]! +} + +type ChatPermission { + id: String! + description: String! + name: String! + createdAt: Date + updatedAt: Date + group: RankPermissionGroup! +} + +""" +The category that the permission is categorized into for Communications ranks. +""" +enum RankPermissionGroup { + ADMIN + MANAGE + GENERAL + OPTIONS +} + +type Message { + id: Int! + createdAt: Date! + updatedAt: Date! + chatId: Int! + userId: Int + content: String + type: MessageType + embeds: [Embed!]! + edited: Boolean! + editedAt: Date + replyId: Int + legacyUserId: Int + pinned: Boolean! + tpuUser: PartialUserBase + reply: Message + legacyUser: PartialUserBase + user: PartialUserBase + readReceipts: [ChatAssociation!]! + chat: Chat! + emoji: [ChatEmoji!] +} + +""" +The type of message. Can be null for legacy (Colubrina) messages where `MESSAGE` should be inferred. +""" +enum MessageType { + MESSAGE + LEAVE + JOIN + PIN + ADMINISTRATOR + RENAME + SYSTEM +} + +type Embed { + type: String! + data: JSON +} + +type ChatEmoji { + id: String! + userId: Int! + chatId: Int! + icon: String + name: String + deleted: Boolean! + createdAt: Date! + updatedAt: Date! +} + +input ChatsInput { + hidden: Boolean! = false +} + +input ChatInput { + associationId: Int + chatId: Int +} + +type Prefix { + prefix: String! + commands: [LookupPrefix!]! +} + +type LookupPrefix { + command: String! + description: String! + botId: Float! +} + +input LookupPrefixInput { + chatAssociationId: Float! + prefix: String! +} + +type Workspace { + id: Int! + name: String! + userId: Int! + createdAt: Date! + updatedAt: Date! + icon: String + user: PartialUserBase! + folders: [WorkspaceFolder!]! + users: [WorkspaceUser!]! +} + +type WorkspaceFolder { + id: Int! + createdAt: Date! + updatedAt: Date! + name: String! + workspaceId: Float! + folderId: Float + children: [Note!]! + workspace: Workspace! +} + +type Note { + id: Int! + createdAt: Date! + updatedAt: Date! + name: String! + data: WorkspaceNote + metadata: WorkspaceNoteMetadata + workspaceFolderId: Float! + shareLink: String + versions: [NoteVersion!]! + permissions: NotePermissionsMetadata +} + +type WorkspaceNote { + version: String + blocks: JSON + time: Float +} + +type WorkspaceNoteMetadata { + version: String +} + +type NoteVersion { + id: String! + noteId: Int! + userId: Int! + data: WorkspaceNote +} + +type NotePermissionsMetadata { + modify: Boolean! + read: Boolean! + configure: Boolean! +} + +type WorkspaceUser { + id: Int! + createdAt: Date! + updatedAt: Date! + workspaceId: Float! + read: Boolean! + write: Boolean! + configure: Boolean! + accepted: Boolean! + recipientId: Int! + senderId: Int! + + """The unique identifier between the User and the Workspace.""" + identifier: String + workspace: Workspace! + user: PartialUserBase! + sender: PartialUserBase! +} + +input NoteInput { + id: Float + shareLink: String +} + +input FriendsInput { + status: FriendStatus +} + +input InfiniteMessagesInput { + associationId: Int! + position: ScrollPosition + search: MessagesSearch + limit: Int! = 50 + offset: Int +} + +"""The position to retrieve messages from based on the `offset`.""" +enum ScrollPosition { + TOP + BOTTOM +} + +input MessagesSearch { + query: String + userId: Int + before: Date + after: Date + pins: Boolean +} + +type PaginatedMessageResponse { + items: [Message!]! + pager: Pager! +} + +input PagedMessagesInput { + associationId: Int! + position: ScrollPosition + search: MessagesSearch + limit: Int! = 50 + page: Int! = 1 +} + +type BlockedUser { + id: String! + userId: Int! + createdAt: Date! + updatedAt: Date! + blockedUserId: Int! + + """ + To the blocked user it appears as though they're unblocked, however the blocker will not receive any messages from them, and their messages will be hidden inside of group chats. + """ + silent: Boolean! + user: PartialUserBase + blockedUser: PartialUserBase +} + +input InviteInput { + inviteId: String! +} + +type ListResponse { + path: String! + name: String! + delimiter: String! + flags: [String!]! + specialUse: String + listed: Boolean! + subscribed: Boolean +} + +input GetMailInput { + userId: Float! + mailbox: String! + page: Float +} + +type OauthApp { + id: String! + name: String! + icon: String + shortCode: String + verified: Boolean! + redirectUri: String + secret: String + description: String + scopes: String! + userId: Float! + botId: Float + private: Boolean! + user: PartialUserBase! + bot: PartialUserBase + oauthUsers: [OauthUser!]! + token: String +} + +type OauthUser { + id: String! + userId: Float! + oauthAppId: String! + active: Boolean! + createdAt: Date! + updatedAt: Date! + manage: Boolean! + user: PartialUserBase! +} + +input MyAppInput { + id: String! +} + +type OauthConsentApp { + id: String! + name: String! + icon: String + shortCode: String + verified: Boolean! + redirectUri: String + description: String + scopes: String! + userId: Float! + botId: Float + private: Boolean! + user: PartialUserBase! + bot: PartialUserBase + token: String +} + +type PaginatedChatAuditLogResponse { + items: [ChatAuditLog!]! + pager: Pager! +} + +type ChatAuditLog { + id: String! + userId: Int! + chatId: Int! + category: AuditLogCategory! + actionType: AuditLogActionType! + message: String! + createdAt: Date! + updatedAt: Date! +} + +"""Used for chat audit log.""" +enum AuditLogCategory { + USER + RANK + MESSAGE + INVITE + PIN_MESSAGE + BOT + SETTINGS + EMOJI +} + +""" +Used for chat audit log to determine what type of action was performed. +""" +enum AuditLogActionType { + MODIFY + ADD + REMOVE +} + +input AuditLogInput { + associationId: Float! + page: Float! = 1 + limit: Float! = 24 +} + +type Mutation { + updateUser(input: UpdateUserInput!): Boolean! + login(input: LoginInput!): LoginResponse! + logout: Boolean! + register(input: RegisterInput!): LoginResponse! + setExperiment(input: SetExperimentInput!): Experiment! + upload(file: File!): Upload! + createChat(input: CreateChatInput!): Chat! + updateChat(input: UpdateChatInput!): Chat! + deleteGroup(input: DangerZoneChatInput!): GenericSuccessObject! + transferGroupOwnership(input: TransferOwnershipInput!): Chat! + addChatUsers(input: AddChatUser!): GenericSuccessObject! + toggleUserRank(input: AddRank!): GenericSuccessObject! + leaveChat(input: LeaveChatInput!): GenericSuccessObject! + joinChatFromInvite(input: JoinChatFromInviteInput!): ChatAssociation! + addBotToChat(input: AddBotToChatInput!): ChatAssociation! + + """Create a new Workspace Folder.""" + createWorkspaceFolder(input: CreateWorkspaceFolderInput!): WorkspaceFolder! + + """Create workspace""" + createWorkspace( + """Name of workspace""" + input: String! + ): Workspace! + + """Delete a Note.""" + deleteWorkspaceItem(input: DeleteWorkspaceItemInput!): Boolean! + saveNote(input: SaveNoteInput!): Note! + createNote(input: CreateNoteInput!): Note! + + """Toggle the ShareLink for a Note.""" + toggleNoteShare( + """ID of Note""" + input: Int! + ): Note! + sendMessage(input: SendMessageInput!): Message! + updateChatRank(input: UpdateRank!): ChatRank! + addChatRank(input: CreateRank!): ChatRank! + updateChatRankOrder(input: UpdateRankOrder!): [ChatRank!]! + deleteChatRank(input: DeleteRank!): GenericSuccessObject! + adminMigrateLegacyRanksForChat: GenericSuccessObject! + blockUser(input: BlockUserInput!): GenericSuccessObject! + createChatInvite(input: CreateInviteInput!): ChatInvite! + oauthAppAuthorize(input: AuthorizeAppInput!): AuthorizeAppResponse! + oauthAppDeauthorize(input: MyAppInput!): GenericSuccessObject! + createOauthApp(input: CreateAppInput!): OauthApp! + deleteOauthApp(input: MyAppInput!): GenericSuccessObject! + resetOauthSecret(input: MyAppInput!): GenericSuccessObject! + updateOauthApp(input: UpdateAppInput!): GenericSuccessObject! + createBotOauthApp(input: CreateBotInput!): PartialUserBase! + updateOauthUser(input: UpdateAppUserInput!): OauthUser! + addOauthUser(input: AddAppUserInput!): OauthUser! + registerBotPrefix(input: RegisterPrefix!): GenericSuccessObject! + registerBotCommands(input: RegisterCommands!): GenericSuccessObject! + updateEmoji(input: UpdateEmojiInput!): ChatEmoji! + deleteEmoji(input: DeleteEmojiInput!): GenericSuccessObject! +} + +input UpdateUserInput { + username: String + email: String + discordPrecache: Boolean + darkTheme: Boolean + description: String + itemsPerPage: Float + storedStatus: String + weatherUnit: String + themeEngine: ThemeEngineInput + insights: String + profileLayout: ProfileLayoutInput + language: String + excludedCollections: [Float!] + publicProfile: Boolean + privacyPolicyAccepted: Boolean + nameColor: String +} + +input ThemeEngineInput { + theme: ThemeEngineThemesInput! + fluidGradient: Boolean! + gradientOffset: String! + defaults: ThemeEngineThemesInput + version: Float! + deviceSync: Boolean! + showOnProfile: Boolean! + baseTheme: String! + customCSS: String +} + +input ThemeEngineThemesInput { + dark: ThemeEngineThemeInput! + light: ThemeEngineThemeInput! + amoled: ThemeEngineThemeInput! +} + +input ThemeEngineThemeInput { + colors: ThemeEngineColorsInput! + dark: Boolean +} + +input ThemeEngineColorsInput { + primary: String! + logo1: String! + logo2: String! + secondary: String! + accent: String! + error: String! + info: String! + success: String! + warning: String! + card: String! + toolbar: String! + sheet: String! + text: String! + dark: String! + gold: String! + background: String! + background2: String! +} + +input ProfileLayoutInput { + layout: ProfileLayoutObjectInput! + config: ProfileLayoutConfigInput! + version: Float! +} + +input ProfileLayoutObjectInput { + columns: [ProfileLayoutColumnInput!]! +} + +input ProfileLayoutColumnInput { + rows: [ProfileLayoutComponentInput!]! +} + +input ProfileLayoutComponentInput { + name: String! + id: String! + props: ProfileLayoutPropsInput +} + +input ProfileLayoutPropsInput { + height: Float + children: [ProfileLayoutComponentInput!] + friendsOnly: Boolean + display: Float + type: String + links: [ProfileLayoutPropLinkInput!] +} + +input ProfileLayoutPropLinkInput { + name: String! + url: String! + color: String! +} + +input ProfileLayoutConfigInput { + containerMargin: Float + showStatsSidebar: Boolean! +} + +type LoginResponse { + token: String! + user: LoginUser! +} + +type LoginUser { + id: Float! + username: String! + email: String! +} + +input LoginInput { + """Username or email""" + username: String! + password: String! + + """TOTP/2FA code if enabled.""" + totp: String +} + +input RegisterInput { + username: String! + password: String! + email: String! + inviteKey: String +} + +input SetExperimentInput { + key: String! + value: Int! + + """Admin only.""" + userId: Int +} + +"""File custom scalar type""" +scalar File + +input CreateChatInput { + users: [Float!]! + name: String +} + +input UpdateChatInput { + name: String + associationId: Int! + + """ + Can only be null or undefined to unset or do not modify the group icon respectively. Use the REST API to set one. + """ + icon: String + + """ + Can only be null or undefined to unset or do not modify the group background respectively. Use the REST API to set one. + """ + background: String + description: String +} + +type GenericSuccessObject { + success: Boolean! +} + +"""Used for deleting chats and transferring ownership.""" +input DangerZoneChatInput { + associationId: Int! + + """You may use either 2FA token or password to delete the chat.""" + password: String + + """ + TOTP/2FA code if enabled. You may use either 2FA token or password to delete the chat. + """ + totp: String +} + +input TransferOwnershipInput { + associationId: Int! + + """You may use either 2FA token or password to delete the chat.""" + password: String + + """ + TOTP/2FA code if enabled. You may use either 2FA token or password to delete the chat. + """ + totp: String + + """User to transfer to.""" + userId: Int! +} + +input AddChatUser { + chatAssociationId: Int! + users: [Float!]! + action: ToggleUser! +} + +"""Whether the user should be added, or removed from the group.""" +enum ToggleUser { + ADD + REMOVE +} + +input AddRank { + chatAssociationId: Int! + updatingChatAssociationId: Int! + rankId: String! +} + +input LeaveChatInput { + associationId: Int! +} + +input JoinChatFromInviteInput { + inviteId: String! +} + +input AddBotToChatInput { + associationId: Float! + botAppId: String! + permissions: [String!]! +} + +input CreateWorkspaceFolderInput { + name: String! + workspaceId: Float! +} + +input DeleteWorkspaceItemInput { + id: Float! + type: WorkspaceItemType! +} + +"""The type of workspace item""" +enum WorkspaceItemType { + NOTE + FOLDER + WORKSPACE +} + +input SaveNoteInput { + id: Float! + data: WorkspaceNoteInput! + manualSave: Boolean + name: String +} + +input WorkspaceNoteInput { + version: String + blocks: JSON + time: Float +} + +input CreateNoteInput { + workspaceFolderId: Float! + name: String! +} + +input SendMessageInput { + content: String! + associationId: Float! + attachments: [String!]! = [] + replyId: Float + embeds: [EmbedInput!] +} + +input EmbedInput { + url: String + title: String + description: String + siteName: String + type: String + image: String + color: String + graph: InteractiveGraphInput +} + +input InteractiveGraphInput { + type: String! +} + +input UpdateRank { + associationId: Int! + rankId: String! + permissionsMap: [String!]! + name: String + color: String +} + +input CreateRank { + associationId: Int! + name: String + color: String +} + +input UpdateRankOrder { + associationId: Int! + + """ + Order if the rank, this is actually reversed from expected index value, so rankIds[0] is the highest priority rank. + """ + rankIds: [String!]! +} + +input DeleteRank { + associationId: Int! + rankId: String! +} + +input BlockUserInput { + userId: Int! + silent: Boolean! +} + +input CreateInviteInput { + """In hours.""" + expiry: Int + + """Auto assign rank on join.""" + rankId: String + associationId: Int! +} + +type AuthorizeAppResponse { + token: String +} + +input AuthorizeAppInput { + id: String! + scopes: String! + + """Used for bots.""" + permissions: [String!] +} + +input CreateAppInput { + name: String! + description: String + redirectUri: String + private: Boolean! + verified: Boolean! +} + +input UpdateAppInput { + name: String! + description: String + redirectUri: String + private: Boolean! + verified: Boolean! + id: String! +} + +input CreateBotInput { + id: String! + username: String! +} + +input UpdateAppUserInput { + id: String! + oauthAppId: String! + manage: Boolean! +} + +input AddAppUserInput { + username: String! + oauthAppId: String! + manage: Boolean +} + +input RegisterPrefix { + prefix: String! +} + +input RegisterCommands { + commands: [RegisterCommand!]! +} + +input RegisterCommand { + command: String! + description: String! +} + +input UpdateEmojiInput { + id: String! + name: String! + associationId: Float! +} + +input DeleteEmojiInput { + id: String! + associationId: Float! +} diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 909a081..9de7b6a 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/troplo/privateuploader/App.kt b/app/src/main/java/com/troplo/privateuploader/App.kt index da30cfc..53ce2be 100644 --- a/app/src/main/java/com/troplo/privateuploader/App.kt +++ b/app/src/main/java/com/troplo/privateuploader/App.kt @@ -2,8 +2,8 @@ package com.troplo.privateuploader import android.app.Application import androidx.startup.AppInitializer -import io.wax911.emojify.EmojiManager -import io.wax911.emojify.initializer.EmojiInitializer +//import io.wax911.emojify.EmojiManager +//import io.wax911.emojify.initializer.EmojiInitializer class TpuApp : Application() { diff --git a/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt b/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt index 274791b..16135ec 100644 --- a/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt +++ b/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt @@ -32,6 +32,7 @@ import coil.request.ImageRequest import coil.size.Size import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import com.troplo.privateuploader.api.SessionManager import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.api.TpuFunctions import com.troplo.privateuploader.api.imageLoader @@ -60,7 +61,7 @@ class FirebaseChatService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { Log.d(TAG, "[NewChatService] Message received") - if (isAppOnForeground(this)) { + if (isAppOnForeground(this) && remoteMessage.data["type"] == "message") { Log.d(TAG, "[NewChatService] App is on foreground") return } @@ -73,7 +74,8 @@ class FirebaseChatService : FirebaseMessagingService() { chatName = remoteMessage.data["chatName"] ?: "", associationId = remoteMessage.data["associationId"]?.toInt() ?: 0, avatar = remoteMessage.data["avatar"] ?: "", - id = remoteMessage.data["id"]?.toInt() ?: 0 + id = remoteMessage.data["id"]?.toInt() ?: 0, + type = remoteMessage.data["type"] ?: "" ) ) } @@ -140,95 +142,121 @@ class FirebaseChatService : FirebaseMessagingService() { // for ActivityCompat#requestPermissions for more details. return } - asyncLoadIcon(message.avatar, this) { - try { - Log.d("TPU.Untagged", "[ChatService] Loaded icon") - val chatPartner = Person.Builder().apply { - setName(message.username) - setKey(message.userId.toString()) - setIcon(it) - setImportant(false) - }.build() - - val notificationManager = NotificationManagerCompat.from(this) - val channel = NotificationChannel( - "communications", - "Messages from Communications", - NotificationManager.IMPORTANCE_HIGH - ) - notificationManager.createNotificationChannel(channel) - if (messages[message.associationId] == null) messages[message.associationId] = - mutableListOf() - messages[message.associationId]?.add( - NotificationCompat.MessagingStyle.Message( - message.content, - TpuFunctions.getDate(message.createdAt)?.time ?: 0, - chatPartner + + if(message.type == "message") { + asyncLoadIcon(message.avatar, this) { + try { + Log.d("TPU.Untagged", "[ChatService] Loaded icon") + val chatPartner = Person.Builder().apply { + setName(message.username) + setKey(message.userId.toString()) + setIcon(it) + setImportant(false) + }.build() + + val notificationManager = NotificationManagerCompat.from(this) + val channel = NotificationChannel( + "communications", + "Messages from Communications", + NotificationManager.IMPORTANCE_HIGH ) - ) + notificationManager.createNotificationChannel(channel) + if (messages[message.associationId] == null) messages[message.associationId] = + mutableListOf() - val style = NotificationCompat.MessagingStyle(chatPartner) - .setConversationTitle(message.chatName) - for (msg in messages[message.associationId]!!) { - style.addMessage(msg) - } + Log.d("TPU.Firebase", "[ChatService] Secure message") + val rep = Intent(this, InlineNotificationActivity::class.java) + rep.replaceExtras(Bundle()) + rep.putExtra("chatId", message.associationId) + Log.d("TPU.Firebase", "[ChatService] ${rep.extras}") + val style = NotificationCompat.MessagingStyle(chatPartner) + .setConversationTitle(message.chatName) + val replyPendingIntent = PendingIntent.getBroadcast( + this@FirebaseChatService, + message.associationId, + rep, + PendingIntent.FLAG_MUTABLE + ) + + val remoteInput = RemoteInput.Builder("content") + .setLabel("Reply") + .build() + + val replyAction = NotificationCompat.Action.Builder( + R.drawable.flowinity_logo, + "Reply", + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .build() + Log.d("TPU.Firebase", "[ChatService] ${message.associationId}") + val builder: NotificationCompat.Builder = + NotificationCompat.Builder(this, "communications") + .addPerson(chatPartner) + .setContentText(message.content) + .setContentTitle(message.username) + .setSmallIcon(R.drawable.flowinity_logo) + .setWhen(TpuFunctions.getDate(message.createdAt)?.time ?: 0) + .addAction(replyAction) + .setContentIntent( + PendingIntent.getActivity( + this, + message.associationId, + Intent(this, MainActivity::class.java).apply { + putExtra("chatId", message.associationId) + }, + PendingIntent.FLAG_MUTABLE + ) + ) + CoroutineScope(Dispatchers.IO).launch { + TpuApi.init(SessionManager(this@FirebaseChatService).getAuthToken() ?: "", this@FirebaseChatService) + val messageRequest = TpuApi.retrofitService.getMessage( + messageId = message.id + ).execute() - val rep = Intent(this, InlineNotificationActivity::class.java) - rep.replaceExtras(Bundle()) - rep.putExtra("chatId", message.associationId) - val replyPendingIntent = PendingIntent.getBroadcast( - this, - message.associationId, - rep, - PendingIntent.FLAG_MUTABLE - ) - - val remoteInput = RemoteInput.Builder("content") - .setLabel("Reply") - .build() - - val replyAction = NotificationCompat.Action.Builder( - R.drawable.tpu_logo, - "Reply", - replyPendingIntent - ) - .addRemoteInput(remoteInput) - .setAllowGeneratedReplies(true) - .build() - - val builder: NotificationCompat.Builder = - NotificationCompat.Builder(this, "communications") - .addPerson(chatPartner) - .setStyle(style) - .setContentText(message.content) - .setContentTitle(message.username) - .setSmallIcon(R.drawable.tpu_logo) - .setWhen(TpuFunctions.getDate(message.createdAt)?.time ?: 0) - .addAction(replyAction) - .setContentIntent( - PendingIntent.getActivity( - this, - message.associationId, - Intent(this, MainActivity::class.java).apply { - putExtra("chatId", message.associationId) - }, - PendingIntent.FLAG_MUTABLE + if (messageRequest.isSuccessful) { + Log.d( + "TPU.Firebase", + "New message came through, ${messageRequest.body()?.content}" ) - ) - val res = notificationManager.notify(message.associationId, builder.build()) - Log.d("TPU.Untagged", "[ChatService] Notification sent, $res") - } catch (e: Exception) { - Log.d( - "TPU.Untagged", - "[ChatService] Error sending notification, ${e.printStackTrace()}" - ) + messages[message.associationId]?.add( + NotificationCompat.MessagingStyle.Message( + messageRequest.body()?.content ?: "", + TpuFunctions.getDate(message.createdAt)?.time ?: 0, + chatPartner + ) + ) + } + + Log.d("TPU.Firebase", "[ChatService] Added message to list") + + for (msg in messages[message.associationId]!!) { + style.addMessage(msg) + } + + builder.setStyle(style) + + val res = notificationManager.notify(message.associationId, builder.build()) + Log.d("TPU.Untagged", "[ChatService] Notification sent, $res") + } + } catch (e: Exception) { + Log.d( + "TPU.Untagged", + "[ChatService] Error sending notification, ${e.printStackTrace()}" + ) + } } + } else if(message.type == "read") { + val notificationManager = NotificationManagerCompat.from(this) + notificationManager.cancel(message.associationId) } } companion object { private const val TAG = "FirebaseChatService" + private const val FAKE_MESSAGE_CONTENT = "Please update your version of the Flowinity app." } internal class MyWorker(appContext: Context, workerParams: WorkerParameters) : diff --git a/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt b/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt index 25c8a94..34782ab 100644 --- a/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt +++ b/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt @@ -18,23 +18,24 @@ class InlineNotificationActivity : BroadcastReceiver() { try { Log.d( "TPU.Untagged", - "[ChatService] InlineNotificationActivity onCreate, intent: $intent, extras: ${intent.extras}" + "[Firebase] InlineNotificationActivity onCreate, intent: $intent, extras: ${intent.extras}" ) val chatId = intent.getIntExtra("chatId", 0) val remoteInput = RemoteInput.getResultsFromIntent(intent) val content = remoteInput?.getCharSequence("content")?.toString() + Log.d("InlineNotificationAct", "Firebase - chatId: $chatId, content: $content") TpuApi.init(SessionManager(context).getAuthToken() ?: "", context) sendReply(chatId, content, context) } catch (e: Exception) { - Log.d("TPU.InlineNotificationActivity", "Exception: $e") + Log.d("TPU.InlineNotificationAct", "Firebase - Exception: $e") } } private fun sendReply(chatId: Int, content: String?, context: Context) { try { if (chatId == 0) return - Log.d("TPU.Untagged", "Sending reply to chatId: $chatId") + Log.d("TPU.Untagged", "Firebase - Sending reply to chatId: $chatId") CoroutineScope(Dispatchers.IO).launch { val response = TpuApi.retrofitService.sendMessage( id = chatId, messageRequest = MessageRequest( @@ -43,7 +44,7 @@ class InlineNotificationActivity : BroadcastReceiver() { ).execute() } } catch (e: Exception) { - Log.d("TPU.InlineNotificationActivity", "sendReply exception: $e") + Log.d("TPU.InlineNotificationAct", "Firebase - sendReply exception: $e") } } } \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/MainActivity.kt b/app/src/main/java/com/troplo/privateuploader/MainActivity.kt index 7fe6596..6313f67 100644 --- a/app/src/main/java/com/troplo/privateuploader/MainActivity.kt +++ b/app/src/main/java/com/troplo/privateuploader/MainActivity.kt @@ -1,6 +1,7 @@ package com.troplo.privateuploader import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -11,6 +12,8 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.toMutableStateList import androidx.core.content.ContextCompat import androidx.startup.AppInitializer import com.google.android.gms.common.GoogleApiAvailability @@ -20,13 +23,15 @@ import com.troplo.privateuploader.api.SocketHandler import com.troplo.privateuploader.api.SocketHandlerService import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.api.TpuFunctions +import com.troplo.privateuploader.api.stores.AppStore import com.troplo.privateuploader.api.stores.CollectionStore +import com.troplo.privateuploader.api.stores.CoreStore import com.troplo.privateuploader.api.stores.UploadStore import com.troplo.privateuploader.api.stores.UserStore import com.troplo.privateuploader.data.model.UploadTarget import com.troplo.privateuploader.ui.theme.PrivateUploaderTheme -import io.wax911.emojify.EmojiManager -import io.wax911.emojify.initializer.EmojiInitializer +//import io.wax911.emojify.EmojiManager +//import io.wax911.emojify.initializer.EmojiInitializer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -38,8 +43,10 @@ import okhttp3.RequestBody.Companion.asRequestBody class MainActivity : ComponentActivity() { + override fun onResume() { super.onResume() + AppStore.foreground = true GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this) val socket = SocketHandler.getSocket() if (socket != null && !socket.connected()) { @@ -47,8 +54,14 @@ class MainActivity : ComponentActivity() { } } + override fun onPause() { + super.onPause() + AppStore.foreground = false + } + override fun onStart() { super.onStart() + AppStore.foreground = true val socket = SocketHandler.getSocket() if (socket != null && !socket.connected()) { socket.connect() @@ -69,6 +82,7 @@ class MainActivity : ComponentActivity() { SocketHandler.initializeSocket(token, this) UserStore.initializeUser(this) } + CoreStore.initializeCore() if (token != null) { setContent { PrivateUploaderTheme( @@ -149,11 +163,20 @@ class MainActivity : ComponentActivity() { } } - fun upload(files: List) { - UploadStore.uploads = files.toMutableList() + public fun upload(files: List, deleteOnceFinished: Boolean = true, context: Context = this): List? { + var uploads = mutableListOf() + Log.d("TPU.Upload", "Uploading ${files.size} files") + + if(!files.any()) { + return null + } + + if(deleteOnceFinished) { + UploadStore.uploads = files.toMutableStateList() + } val filesBody = files.map { file -> - TpuFunctions.uriToFile(file.uri, this, file.name) + TpuFunctions.uriToFile(file.uri, context, file.name) } CoroutineScope(Dispatchers.IO).launch { @@ -185,16 +208,34 @@ class MainActivity : ComponentActivity() { } val response = TpuApi.retrofitService.uploadFiles(parts).execute() - response.body()?.let { + response.body()?.let { upload -> UploadStore.globalProgress.value = 0f - UploadStore.uploads = mutableListOf() + if(deleteOnceFinished) { + UploadStore.uploads.clear() + } else { + // set the upload status to finished + UploadStore.uploads.find { it.uri == files.first().uri }?.let { + it.progress = 100f + it.url = upload[0].upload.attachment + } + Log.d("TPU.Upload", "Upload finished: ${upload[0].upload.attachment}, ${UploadStore.uploads.toList().toString()}") + } + + uploads = upload.map { it.upload.attachment }.toMutableList() + } + + return@launch withContext(Dispatchers.Main) { + uploads } } + + return null } - @Deprecated("Deprecated in Java") + @Deprecated("Deprecated in Java :(") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + Log.d("TPU.Upload", "Upload response received (MainActivity)") if (requestCode == UploadStore.intentCode && resultCode == RESULT_OK) { if (data != null) { if (data.clipData != null) { @@ -247,11 +288,11 @@ class MainActivity : ComponentActivity() { } - internal val emojiManager: EmojiManager by lazy { - // should already be initialized if we haven't disabled initialization in manifest - // see: https://developer.android.com/topic/libraries/app-startup#disable-individual - AppInitializer.getInstance(this) - .initializeComponent(EmojiInitializer::class.java) - } +// internal val emojiManager: EmojiManager by lazy { +// should already be initialized if we haven't disabled initialization in manifest +// see: https://developer.android.com/topic/libraries/app-startup#disable-individual +// AppInitializer.getInstance(this) +// .initializeComponent(EmojiInitializer::class.java) +// } } diff --git a/app/src/main/java/com/troplo/privateuploader/MainScreen.kt b/app/src/main/java/com/troplo/privateuploader/MainScreen.kt index 52bd6fb..3161ce8 100644 --- a/app/src/main/java/com/troplo/privateuploader/MainScreen.kt +++ b/app/src/main/java/com/troplo/privateuploader/MainScreen.kt @@ -4,8 +4,10 @@ import android.util.Log import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedDispatcher import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,9 +15,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -136,19 +139,20 @@ fun MainScreen() { } ) { paddingValues -> OverlappingPanels( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.background(MaterialTheme.colorScheme.surface).fillMaxSize(), panelsState = panelState, gesturesEnabled = navController.currentDestination?.route?.startsWith("chat/") == true || !panelState.isPanelsClosed, panelStart = { PanelSurface { ModalDrawerSheet( modifier = Modifier.padding( - top = paddingValues.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() - ), + // hack for a16: originally calculateTopPadding but value too high + top = 64.dp, + bottom = 80.dp + ) + .background(MaterialTheme.colorScheme.surfaceContainer), drawerTonalElevation = if(isAMOLED.value) 0.dp else DrawerDefaults.ModalDrawerElevation ) { - Spacer(Modifier.height(12.dp)) HomeScreen( openChat = { chatId -> if(ChatStore.associationId.value != chatId) { @@ -167,7 +171,8 @@ fun MainScreen() { PanelSurface { NavGraph( modifier = Modifier.padding( - top = paddingValues.calculateTopPadding(), + // hack for a16: originally calculateTopPadding but value too high + top = 64.dp, bottom = if (navController.currentDestination?.route?.startsWith("chat/") == true) 0.dp else paddingValues.calculateBottomPadding() ), navController = navController, @@ -180,17 +185,20 @@ fun MainScreen() { panelEnd = { PanelSurface { ModalDrawerSheet( - modifier = Modifier.padding( - top = paddingValues.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .padding( + // hack for a16: originally calculateTopPadding but value too high + top = 64.dp, + bottom = 0.dp ), drawerTonalElevation = if(isAMOLED.value) 0.dp else DrawerDefaults.ModalDrawerElevation ) { Column( modifier = Modifier .verticalScroll(rememberScrollState()) - .weight(weight = 1f, fill = false) - + .background(MaterialTheme.colorScheme.surfaceContainer) + .weight(weight = 1f, fill = true) ) { Spacer(Modifier.height(12.dp)) MemberSidebar() diff --git a/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt b/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt index f2d35a5..81fc69b 100644 --- a/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt +++ b/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt @@ -18,6 +18,7 @@ class UploadResponseActivity: Activity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == UploadStore.intentCode && resultCode == RESULT_OK) { + Log.d("TPU.UploadResponse", "Upload response received") if (data != null) { if (data.clipData != null) { // Multiple files were selected diff --git a/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt b/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt index 33e778e..5845cc1 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt @@ -10,6 +10,7 @@ import com.troplo.privateuploader.data.model.Chat import com.troplo.privateuploader.data.model.ChatCreateRequest import com.troplo.privateuploader.data.model.Collection import com.troplo.privateuploader.data.model.CollectionItem +import com.troplo.privateuploader.data.model.Collections import com.troplo.privateuploader.data.model.CollectivizeRequest import com.troplo.privateuploader.data.model.CreateCollectionRequest import com.troplo.privateuploader.data.model.EditRequest @@ -317,7 +318,7 @@ object TpuApi { ): Call @GET("collections") - fun getCollections(): Call> + fun getCollections(): Call @POST("collections/attachment") fun collectivize( @@ -388,6 +389,11 @@ object TpuApi { @Path("associationId") associationId: Int, @Body pinRequest: PinRequest ): Call + + @GET("chats/messages/{messageId}") + fun getMessage( + @Path("messageId") messageId: Int + ): Call } val retrofitService: TpuApiService by lazy { diff --git a/app/src/main/java/com/troplo/privateuploader/api/Functions.kt b/app/src/main/java/com/troplo/privateuploader/api/Functions.kt index 9dfa9ee..e1cbab7 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/Functions.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/Functions.kt @@ -3,6 +3,7 @@ package com.troplo.privateuploader.api import android.content.Context import android.net.Uri import android.util.Log +import com.troplo.privateuploader.api.stores.CoreStore import com.troplo.privateuploader.api.stores.FriendStore import com.troplo.privateuploader.data.model.Chat import com.troplo.privateuploader.data.model.PartialUser @@ -22,9 +23,15 @@ import kotlin.math.ln import kotlin.math.pow object TpuFunctions { - fun image(link: String?, recipient: User?): String? { + fun image(link: String?, recipient: User?, width: Int? = null, height: Int? = null): String? { + val coreStore = CoreStore.core.value + val domain = "https://${coreStore?.domain ?: "i.troplo.com"}" if (recipient?.avatar != null) { - return "https://i.troplo.com/i/${recipient.avatar}" + var string = "$domain/i/${recipient.avatar}" + if (width != null && height != null) { + string += "?width=$width&height=$height" + } + return string } if (link == null) { return null @@ -32,7 +39,11 @@ object TpuFunctions { return if (link.length >= 20) { "https://colubrina.troplo.com/usercontent/$link" } else { - "https://i.troplo.com/i/$link" + var string = "$domain/i/$link" + if (width != null && height != null) { + string += "?width=$width&height=$height" + } + return string } } @@ -108,9 +119,9 @@ object TpuFunctions { return ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT) } - fun fileSize(size: Int?): String { - if (size == null || size == 0) return "0B" - val unit = 1024 + fun fileSize(size: Long?): String { + if (size == null || size == 0L) return "0B" + val unit = 1024L if (size < unit) return "${size}B" val exp = (ln(size.toDouble()) / ln(unit.toDouble())).toInt() val pre = "KMGTPE"[exp - 1] + "i" diff --git a/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt b/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt index c176075..8254efb 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt @@ -7,7 +7,9 @@ import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.disk.DiskCache import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.newFixedThreadPoolContext +val dispatcher = newFixedThreadPoolContext(2, "CoilDispatcherTPU") fun imageLoader(context: Context, cache: Boolean? = true): ImageLoader { return ImageLoader.Builder(context) .components { @@ -17,7 +19,7 @@ fun imageLoader(context: Context, cache: Boolean? = true): ImageLoader { add(GifDecoder.Factory()) } } - .dispatcher(Dispatchers.IO) + .dispatcher(dispatcher) .diskCache { if (cache == true) { DiskCache.Builder() diff --git a/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt b/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt index df3ffba..bf79510 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt @@ -9,19 +9,28 @@ import com.troplo.privateuploader.BuildConfig import com.troplo.privateuploader.data.model.MessageEvent import com.troplo.privateuploader.data.model.Typing import io.socket.client.IO +import io.socket.client.Manager import io.socket.client.Socket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.json.JSONObject +import java.net.URI import java.net.URISyntaxException import java.util.Collections import java.util.concurrent.Executors + object SocketHandler { var baseUrl = BuildConfig.SERVER_URL - private var socket: Socket? = null + private var chatSocket: Socket? = null + private var gallerySocket: Socket? = null + private var userSocket: Socket? = null + private var friendsSocket: Socket? = null + private var trackedUsersSocket: Socket? = null + + private var manager: Manager? = null val gson = Gson() var connected = mutableStateOf(false) @@ -30,63 +39,88 @@ object SocketHandler { val options = IO.Options() options.forceNew = true options.reconnection = true + Log.d("TPU.SocketToken", token) options.auth = Collections.singletonMap("token", token) - options.query = "platform=$platform&version=3" + options.query = "platform=$platform&version=4" options.transports = arrayOf("websocket") options.reconnectionDelay = 1000 options.reconnectionDelayMax = 5000 - options.reconnectionAttempts = 99999 - socket = IO.socket(baseUrl, options) - if (socket != null) { - socket?.open() + options.reconnectionAttempts = 9999 + options.path = "/gateway" + manager = Manager(URI(baseUrl), options) + chatSocket = manager!!.socket("/chat", options) + gallerySocket = manager!!.socket("/gallery", options) + userSocket = manager!!.socket("/user", options) + friendsSocket = manager!!.socket("/friends", options) + trackedUsersSocket = manager!!.socket("/trackedUsers", options) + + gallerySocket?.open() + userSocket?.open() + friendsSocket?.open() + trackedUsersSocket?.open() + + if (chatSocket != null) { + chatSocket?.open() if (platform !== "android_kotlin_background_service") { - socket?.on(Socket.EVENT_CONNECT) { + chatSocket?.on(Socket.EVENT_CONNECT) { this.connected.value = true Log.d( "TPU.Untagged", - "Socket connected ${socket?.isActive}, Connected: ${this.connected.value}" + "Socket connected ${chatSocket?.isActive}, Connected: ${this.connected.value}" ) } - socket?.on(Socket.EVENT_DISCONNECT) { + chatSocket?.on(Socket.EVENT_DISCONNECT) { this.connected.value = false Log.d( "TPU.Untagged", - "Socket disconnected ${socket?.isActive}, Connected: ${this.connected.value}" + "Socket disconnected ${chatSocket?.isActive}, Connected: ${this.connected.value}, Error: ${it[0]}" ) } - socket?.on(Socket.EVENT_CONNECT_ERROR) { + chatSocket?.on(Socket.EVENT_CONNECT_ERROR) { try { this.connected.value = false Log.d( "TPU.Untagged", - "Socket connect error ${socket?.isActive}, Connected: ${this.connected.value}, Error: ${it[0]}" + "Socket connect error ${chatSocket?.isActive}, Connected: ${this.connected.value}, Error: ${it[0]}, URL: ${baseUrl}/gateway" ) } catch (e: Exception) { // } } - socket?.on("message") { it -> + chatSocket?.on("message") { it -> val jsonArray = it[0] as JSONObject val payload = jsonArray.toString() val messageEvent = gson.fromJson(payload, MessageEvent::class.java) - val message = messageEvent.message + // Change in v4 uses uppercase type + val message = messageEvent.message.copy( + type = messageEvent.message.type?.lowercase() + ) + Log.d("TPU.Untagged", "Message received (SocketHandler): $message") - if (messageEvent.association.id != ChatStore.associationId.value) { + val chat = + ChatStore.chats.find { it.association?.id?.toInt() == messageEvent.associationId.toInt() } + val unread = chat?.unread ?: 0 + Log.d("MessageStore", "${messageEvent.associationId}, ${ChatStore.associationId.value}") + Log.d("MessageStore", "Chat: $chat") + if (messageEvent.associationId != ChatStore.associationId.value) { // increase unread count - val chat = - ChatStore.chats.value.find { it.association?.id == messageEvent.association.id } - Log.d("TPU.Untagged", chat.toString()) - if (chat != null) { - chat.unread = chat.unread?.plus(1) - ChatStore.setChats(listOf(chat) + ChatStore.chats.value.filter { it.association?.id != messageEvent.association.id }) + Log.d("MessageStore", "Increasing unread count: ${chat?.unread?.plus(1)}") + unread.plus(1) + } + Log.d("MessageStore", "Unread; $unread, Chat: $chat") + if(chat != null) { + val modifiedChats = ChatStore.chats.toMutableList() + val index = modifiedChats.indexOfFirst { it.id == chat.id } + if (index != -1) { + modifiedChats.removeAt(index) + modifiedChats.add(0, chat.copy(unread = unread)) + ChatStore.chats.clear() + ChatStore.chats.addAll(modifiedChats) } } - - // if running in background, send notification - } - socket?.on("typing") { it -> + chatSocket?.on("typing") { it -> CoroutineScope(Dispatchers.IO).launch { val jsonArray = it[0] as JSONObject val payload = jsonArray.toString() @@ -111,7 +145,7 @@ object SocketHandler { } } } - Log.d("TPU.Untagged", "Socket connected ${socket?.isActive}") + Log.d("TPU.Untagged", "Socket connected ${chatSocket?.isActive}") } else { Log.d("TPU.Untagged", "Socket is null") } @@ -129,11 +163,29 @@ object SocketHandler { fun getSocket(): Socket? { - return socket + return chatSocket + } + + fun getGallerySocket(): Socket? { + return gallerySocket + } + + fun getUserSocket(): Socket? { + return userSocket + } + + fun getFriendsSocket(): Socket? { + return friendsSocket + } + + fun getTrackedUsersSocket(): Socket? { + return trackedUsersSocket } fun closeSocket() { - socket?.disconnect() - socket = null + chatSocket?.disconnect() + chatSocket = null + gallerySocket?.disconnect() + gallerySocket = null } } \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt b/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt index 7e85f09..7807907 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt @@ -22,12 +22,12 @@ object SocketHandlerService { options.forceNew = true options.reconnection = true options.auth = Collections.singletonMap("token", token) - options.query = "platform=$platform&version=3" + options.query = "platform=$platform&version=4" options.transports = arrayOf("websocket") options.reconnectionDelay = 1000 options.reconnectionDelayMax = 5000 options.reconnectionAttempts = 99999 - socket = IO.socket(baseUrl, options) + socket = IO.socket("$baseUrl/gateway", options) if (socket != null) { socket?.open() Log.d("TPU.Untagged", "Socket connected ${socket?.isActive}") diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt index 5927c44..47b712f 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt @@ -5,4 +5,5 @@ import androidx.navigation.NavController object AppStore { var navController: NavController? = null + var foreground = false } \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt index 61d4dd1..c91328a 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import com.troplo.privateuploader.api.stores.AppStore import com.troplo.privateuploader.data.model.AddChatUsersEvent import com.troplo.privateuploader.data.model.Chat import com.troplo.privateuploader.data.model.PinRequest @@ -22,24 +23,19 @@ import java.net.URISyntaxException object ChatStore { - private val _chats: MutableStateFlow> = MutableStateFlow(emptyList()) + val chats = mutableStateListOf() var associationId = MutableStateFlow(0) var typers = MutableStateFlow(emptyList()) var jumpToMessage = MutableStateFlow(0) var hasInit = false val searchPanel = MutableStateFlow(false) - // To upload to TPU, uses URI Android system - var attachmentsToUpload = mutableStateListOf() - - val chats: StateFlow> - get() = _chats - fun initializeChats() { try { CoroutineScope(Dispatchers.IO).launch { val response = TpuApi.retrofitService.getChats().execute().body() ?: emptyList() - _chats.value = response + chats.clear() + chats.addAll(response) } if(hasInit) return hasInit = true @@ -50,30 +46,27 @@ object ChatStore { val payload = jsonArray.toString() val chat = SocketHandler.gson.fromJson(payload, Chat::class.java) // add it to the top - _chats.value = listOf(chat).plus(_chats.value) + chats.add(0, chat) } socket.on("removeChat") { val jsonArray = it[0] as JSONObject val payload = jsonArray.toString() val chat = SocketHandler.gson.fromJson(payload, RemoveChatEvent::class.java) - _chats.value = _chats.value.filter { it.id != chat.id } + val index = chats.indexOfFirst { it.id == chat.id } + chats.removeAt(index) } socket.on("removeChatUser") { val jsonArray = it[0] as JSONObject val payload = jsonArray.toString() val assoc = SocketHandler.gson.fromJson(payload, RemoveChatUserEvent::class.java) - val chatIndex = _chats.value.indexOfFirst { it.id == assoc.chatId } + val chatIndex = chats.indexOfFirst { it.id == assoc.chatId } if (chatIndex != -1) { - val chat = _chats.value[chatIndex] + val chat = chats[chatIndex] val userIndex = chat.users.indexOfFirst { it.id == assoc.id } if (userIndex != -1) { - _chats.value = _chats.value.toMutableList().apply { - this[chatIndex] = chat.copy(users = chat.users.toMutableList().apply { - this.removeAt(userIndex) - }) - } + chats[chatIndex].users.toMutableList().removeAt(userIndex) } } } @@ -83,14 +76,12 @@ object ChatStore { val payload = jsonArray.toString() val users = SocketHandler.gson.fromJson(payload, AddChatUsersEvent::class.java) - val chatIndex = _chats.value.indexOfFirst { it.id == users.chatId } + val chatIndex = chats.indexOfFirst { it.id == users.chatId } if (chatIndex != -1) { - val chat = _chats.value[chatIndex] - _chats.value = _chats.value.toMutableList().apply { - this[chatIndex] = chat.copy(users = chat.users.toMutableList().apply { - this.addAll(users.users) - }) - } + val chat = chats[chatIndex] + chats[chatIndex] = chats[chatIndex].copy(users = chats[chatIndex].users.toMutableList().apply { + addAll(users.users) + }) } } } else { @@ -102,7 +93,7 @@ object ChatStore { } fun getChat(): Chat? { - return chats.value.find { it.association?.id == associationId.value } + return chats.find { it.association?.id == associationId.value } } fun setAssociationId(id: Int, context: Context) { @@ -110,18 +101,19 @@ object ChatStore { associationId.value = id // Handle unread count, and init read receipt + // Ensure that the app is in the foreground + val socket = SocketHandler.getSocket() - socket?.emit("readChat", id) - val chat = chats.value.find { it.association?.id == id } - if (chat != null) { - chat.unread = 0 + Log.d("MarkAsRead", "Foreground: ${AppStore.foreground}") + if(AppStore.foreground) { + socket?.emit("readChat", id) + val chat = chats.find { it.association?.id == id } + if (chat != null) { + chat.unread = 0 + } } } - fun setChats(chats: List) { - _chats.value = chats - } - fun deleteMessage(messageId: Int) { if (messageId == 0 || associationId.value == 0) return diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt index 92ce6b5..110d37b 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt @@ -2,6 +2,8 @@ package com.troplo.privateuploader.api.stores import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.data.model.Collection +import com.troplo.privateuploader.data.model.Collections +import com.troplo.privateuploader.data.model.Pager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -10,7 +12,7 @@ import java.net.URISyntaxException object CollectionStore { - var collections: MutableStateFlow> = MutableStateFlow(listOf()) + var collections: MutableStateFlow = MutableStateFlow(Collections(listOf(), Pager(0, 0, 0, 0, listOf(), 0, 0, 0, 0))) fun initializeCollections() { try { diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/CoreStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/CoreStore.kt new file mode 100644 index 0000000..3451cf1 --- /dev/null +++ b/app/src/main/java/com/troplo/privateuploader/api/stores/CoreStore.kt @@ -0,0 +1,35 @@ +package com.troplo.privateuploader.api.stores + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.troplo.privateuploader.api.SessionManager +import com.troplo.privateuploader.api.SocketHandler +import com.troplo.privateuploader.api.SocketHandlerService +import com.troplo.privateuploader.api.TpuApi +import com.troplo.privateuploader.data.model.Friend +import com.troplo.privateuploader.data.model.State +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + + +object CoreStore { + var core: MutableState = mutableStateOf(null) + + fun initializeCore() { + CoroutineScope( + Dispatchers.IO + ).launch { + val data = TpuApi.retrofitService.getInstanceInfo().execute() + launch(Dispatchers.Main) { + if (data.isSuccessful) { + val body = data.body()!! + core.value = body + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt index 477e59d..bc280f2 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt @@ -19,8 +19,9 @@ object FriendStore { fun initializeFriends() { try { - val socket = SocketHandler.getSocket() - socket?.off("userStatus") + val socket = SocketHandler.getFriendsSocket(); + val tracked = SocketHandler.getTrackedUsersSocket() + tracked?.off("userStatus") socket?.off("friendRequest") CoroutineScope( @@ -32,26 +33,32 @@ object FriendStore { } } + Log.d("FriendStore", "initialized") - socket?.on("userStatus") { it -> + tracked?.on("userStatus") { it -> val jsonArray = it[0] as JSONObject val payload = jsonArray.toString() - Log.d("TPU.Untagged", payload) + Log.d("FriendStore.UserStatus", payload) val status = SocketHandler.gson.fromJson(payload, StatusPayload::class.java) val friend = friends.value.find { it.otherUser?.id == status.id } + Log.d("FriendStore.UserStatus", "friend: $friend") + if (friend != null) { friends.value = friends.value.minus(friend).plus( friend.copy( otherUser = friend.otherUser?.copy( - status = status.status ?: "offline", + status = status.status?.lowercase() ?: "offline", platforms = status.platforms ) ) ) } else if (status.id == UserStore.user.value?.id) { + Log.d("FriendStore.UserStatus", "updating current user status") UserStore.user.value = UserStore.user.value?.copy( - status = status.status ?: "offline", + status = status.status?.lowercase() ?: "offline", + storedStatus = if (status.status?.lowercase() === "offline") "invisible" else status.status?.lowercase() + ?: "offline", platforms = status.platforms ) } @@ -87,7 +94,13 @@ object FriendStore { fun updateFriendNickname(name: String, userId: Int) { friends.value = friends.value.map { if (it.otherUser?.id == userId) { - it.copy(otherUser = it.otherUser?.copy(nickname = it.otherUser?.nickname?.copy(nickname = name))) + it.copy( + otherUser = it.otherUser?.copy( + nickname = it.otherUser?.nickname?.copy( + nickname = name + ) + ) + ) } else { it } diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt index 7fdf584..43da8a7 100644 --- a/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt +++ b/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt @@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.core.app.ActivityCompat.startActivityForResult import com.troplo.privateuploader.MainActivity import com.troplo.privateuploader.UploadResponseActivity @@ -16,7 +17,7 @@ import java.util.Date object UploadStore { var intentCode = 0 var filePath: String = "" - var uploads = mutableListOf() + var uploads = mutableStateListOf() var globalProgress: MutableState = mutableFloatStateOf(0f) fun requestUploadIntent(activity: Activity) { @@ -26,6 +27,6 @@ object UploadStore { putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) type = "*/*" } - startActivityForResult(activity, intent, intentCode, null) + activity.startActivityForResult(intent, intentCode, null) } } \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt index 1388f0e..c1bb7a9 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt @@ -1,12 +1,18 @@ package com.troplo.privateuploader.components.chat +import android.Manifest +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.GifBox import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Star import androidx.compose.material3.ExperimentalMaterial3Api @@ -23,18 +29,28 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.ViewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.troplo.privateuploader.MainActivity import com.troplo.privateuploader.api.ChatStore +import com.troplo.privateuploader.api.stores.UploadStore import com.troplo.privateuploader.api.stores.UserStore import com.troplo.privateuploader.components.chat.attachment.MyDevice import com.troplo.privateuploader.data.model.Upload import com.troplo.privateuploader.data.model.UploadTarget import com.troplo.privateuploader.screens.GalleryScreen +import com.troplo.privateuploader.screens.getFileName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun Attachment(openBottomSheet: MutableState) { val windowInsets = WindowInsets(0) @@ -45,9 +61,24 @@ fun Attachment(openBottomSheet: MutableState) { ModalBottomSheet( onDismissRequest = { openBottomSheet.value = false }, sheetState = bottomSheetState, - windowInsets = windowInsets, +// windowInsets = windowInsets, modifier = Modifier.defaultMinSize(minHeight = 400.dp) ) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { data -> + Log.d("TPU.UploadResponse", "Upload response received, data: $data") + for (uri in data) { + UploadStore.uploads.add( + UploadTarget( + uri = uri, + started = false, + progress = 0f, + name = getFileName(uri, context) + ) + ) + } + } + TabRow( selectedTabIndex = selectedTab.value, contentColor = MaterialTheme.colorScheme.onSurface @@ -55,7 +86,7 @@ fun Attachment(openBottomSheet: MutableState) { Tab( selected = selectedTab.value == 0, onClick = { selectedTab.value = 0 }, - text = { Text("My Device") }, + text = { Text("Device") }, icon = { Icon(Icons.Default.Smartphone, contentDescription = "My Device") } ) Tab( @@ -76,11 +107,24 @@ fun Attachment(openBottomSheet: MutableState) { text = { Text("GIFs") }, icon = { Icon(Icons.Default.GifBox, contentDescription = "GIFs") } ) + + val filesPermissionState = rememberPermissionState( + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + Tab( + selected = selectedTab.value == 4, + onClick = { + launcher.launch(arrayOf("*/*")) + }, + text = { Text("Other") }, + icon = { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open in File Manager") } + ) } fun onClick(upload: Upload, tenor: Boolean = false) { openBottomSheet.value = false - ChatStore.attachmentsToUpload.add( + UploadStore.uploads.add( UploadTarget( uri = if (tenor) { upload.attachment.toUri() diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt index b4af568..3efdd4b 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt @@ -56,7 +56,7 @@ fun ChatActions( ModalBottomSheet( onDismissRequest = { openBottomSheet.value = false }, sheetState = bottomSheetState, - windowInsets = windowInsets +// windowInsets = windowInsets ) { Column( modifier = Modifier.padding(bottom = 8.dp), diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt index e4bf41c..70d74b2 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt @@ -1,6 +1,5 @@ package com.troplo.privateuploader.components.chat -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Badge @@ -9,16 +8,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.troplo.privateuploader.api.ChatStore import com.troplo.privateuploader.api.TpuFunctions +import com.troplo.privateuploader.components.core.NavRoute import com.troplo.privateuploader.components.core.UserAvatar import com.troplo.privateuploader.data.model.Chat @@ -26,7 +25,7 @@ import com.troplo.privateuploader.data.model.Chat @Composable fun ChatItem( chat: Chat, - openChat: (Int) -> Unit + navController: NavController, ) { val chatName = TpuFunctions.getChatName(chat) // track ChatStore.associationId, is mutableStateOf(0) @@ -55,7 +54,9 @@ fun ChatItem( .fillMaxWidth(), onClick = { chat.association?.let { - openChat(it.id) + if (navController.currentDestination?.route?.startsWith("chat/") == false || id.value != it.id) { + navController.navigate("${NavRoute.Chat.path}/${it.id}") + } } }, label = { @@ -78,11 +79,12 @@ fun ChatItem( ) }*/ }, - selected = id.value == chat.association?.id, + selected = navController.currentDestination?.route?.startsWith("chat/") == true && id.value == chat.association?.id, icon = { UserAvatar( avatar = chat.icon ?: chat.recipient?.avatar, - username = chatName + username = chat.recipient?.username ?: chatName, + showStatus = chat.recipient != null ) } ) diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt index 191bba4..ff244e5 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt @@ -2,15 +2,14 @@ package com.troplo.privateuploader.components.chat import android.content.Intent import android.net.Uri -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -28,143 +27,165 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest -import coil.size.Size import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.glide.GlideImage import com.troplo.privateuploader.api.TpuFunctions -import com.troplo.privateuploader.api.imageLoader +import com.troplo.privateuploader.api.stores.CoreStore import com.troplo.privateuploader.api.stores.UserStore import com.troplo.privateuploader.components.chat.dialogs.ImageDialog import com.troplo.privateuploader.data.model.Embed -import kotlinx.coroutines.Dispatchers +import com.troplo.privateuploader.data.model.EmbedDataV2 +import com.troplo.privateuploader.data.model.EmbedMedia +import com.troplo.privateuploader.data.model.EmbedMediaType @OptIn(ExperimentalGlideComposeApi::class) @Composable -fun Embed(embed: Embed) { - if (embed.data != null) { - val url = - if (embed.data.type == "TPU_DIRECT") embed.data.url else "https://" + UserStore.getUser()?.domain?.domain + embed.data.url - when (embed.type) { - "openGraph" -> { +fun Embed(embed: EmbedDataV2) { + if (embed.metadata != null) { + val context = LocalContext.current + val coreStore = CoreStore.core.value + if (embed.metadata.siteName?.isNotEmpty() == true) { + Card { + Column { + Text( + text = embed.metadata.siteName.orEmpty(), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + EmbedText(embed) + } + } + } + + if (embed.metadata.siteName?.isEmpty() == true) { Card { Column { - Text( - text = embed.data.siteName.orEmpty(), - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodySmall - ) - Text( - text = embed.data.title.orEmpty(), - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = embed.data.description.orEmpty(), - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) + EmbedText(embed) } } } - "image" -> { - val expand = remember { mutableStateOf(false) } - if (expand.value) { - ImageDialog(url ?: "", embed.data.upload?.name ?: "unknown.png", expand) - } - Image( - contentDescription = "Embed image (no alt text)", - painter = rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current) - .dispatcher(Dispatchers.IO) - .data(data = url) - .apply(block = fun ImageRequest.Builder.() { - size(Size.ORIGINAL) - }).build(), imageLoader = imageLoader(LocalContext.current, false) - ), - modifier = Modifier - .fillMaxWidth() - .height(if (embed.data.height!! > 300) 300.dp else embed.data.height.dp) - .clickable { - expand.value = true - } - ) - - } + for (media in embed.media ?: emptyList()) { + if (media.type == 0) { + val url = + if (!media.isInternal) "https://${coreStore?.domain ?: "https://flowinity.com/"}${media.proxyUrl}" else "https://" + UserStore.getUser()?.domain?.domain + "/i/" + media.attachment + val expand = remember { mutableStateOf(false) } + if (expand.value) { + ImageDialog( + url ?: "", + media.upload?.name ?: media.attachment ?: media.url ?: "unknown.png", + expand + ) + } - "file" -> { - val context = LocalContext.current - Card { - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 16.dp, top = 16.dp) - ) { - Icon( - imageVector = Icons.Default.InsertDriveFile, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text( - text = embed.data.upload?.name.orEmpty(), - modifier = Modifier.padding(start = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 16.dp) - ) { - Text( - text = TpuFunctions.fileSize(embed.data.upload?.fileSize), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.weight(0.1f)) - IconButton( - onClick = { - val attachment = embed.data.upload?.attachment - if (attachment != null) { + GlideImage( + imageModel = { url }, + modifier = Modifier + .fillMaxSize() + .height(if (media.height!! > 300) 300.dp else media.height.dp) + .clickable { + expand.value = true + }, + imageOptions = ImageOptions( + contentScale = ContentScale.FillWidth + ) + ) + } else if ((media.type == 3 || media.type == 1) && media.upload != null) { + Card { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 16.dp) + ) { + Icon( + imageVector = Icons.Default.InsertDriveFile, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text( + text = media.upload.name, + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = TpuFunctions.fileSize(media.upload.fileSize), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.weight(0.1f)) + IconButton( + onClick = { + val attachment = media.upload.attachment val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse("https://${UserStore.getUser()?.domain?.domain}/i/${attachment}?force=true") context.startActivity(intent) } + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null + ) } - ) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = null - ) } } } + } else if(media.type == 3 || media.type == 1) { + Card( + modifier = Modifier + .width(300.dp) + .heightIn(0.dp, 200.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "This file has been deleted by the owner.", + modifier = Modifier.padding(16.dp) + ) + } + } + } else { + Card( + modifier = Modifier + .width(300.dp) + .heightIn(0.dp, 200.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "This embed cannot be loaded.\n\nType: ${media.type}\n\nURL: ${media.url}", + modifier = Modifier.padding(16.dp) + ) + } + } } } - - else -> { - Card { - Text( - text = "The version of TPUvNATIVE you are using does not yet support the embed type ${embed.type}!", - modifier = Modifier.padding(16.dp) - ) - } - } - } - } else { - Card( - modifier = Modifier.width(300.dp) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + } else { + Card( + modifier = Modifier + .width(300.dp) + .heightIn(0.dp, 200.dp) ) { - Text(text = "This embed cannot be loaded.", modifier = Modifier.padding(16.dp)) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "This embed cannot be loaded.", modifier = Modifier.padding(16.dp)) + } } } - } -} \ No newline at end of file + } \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/EmbedText.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/EmbedText.kt new file mode 100644 index 0000000..9f54867 --- /dev/null +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/EmbedText.kt @@ -0,0 +1,21 @@ +package com.troplo.privateuploader.components.chat + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.troplo.privateuploader.data.model.EmbedDataV2 + +@Composable +fun EmbedText(embed: EmbedDataV2) { + for (text in embed.text ?: emptyList()) { + Text( + text = text.text, + modifier = Modifier.padding(top = 8.dp), + style = if(text.heading == true) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt index a531698..2e176ae 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.ResourcesCompat -import com.troplo.privateuploader.TpuApp import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonConfiguration @@ -35,13 +34,11 @@ import io.noties.markwon.ext.strikethrough.StrikethroughPlugin import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.linkify.LinkifyPlugin -import io.wax911.emojify.parser.parseToHtmlDecimal -import io.wax911.emojify.parser.parseToUnicode @Composable fun MarkdownText( - markdown: String, + content: String, modifier: Modifier = Modifier, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, @@ -53,7 +50,7 @@ fun MarkdownText( onClick: (() -> Unit)? = null, // this option will disable all clicks on links, inside the markdown text // it also enable the parent view to receive the click event - onLinkClicked: ((String) -> Unit)? = null, + onLinkClicked: (() -> Unit)? = null, onTextLayout: ((numLines: Int) -> Unit)? = null, onLongClick: (() -> Unit)? = null, ) { @@ -75,12 +72,11 @@ fun MarkdownText( maxLines = maxLines, style = style, textAlign = textAlign, - viewId = viewId, - onClick = onClick + viewId = viewId ) }, update = { textView -> - markdownRender.setMarkdown(textView, markdown) + markdownRender.setMarkdown(textView, content) if (onTextLayout != null) { textView.post { onTextLayout(textView.lineCount) @@ -88,8 +84,11 @@ fun MarkdownText( } textView.maxLines = maxLines textView.setOnLongClickListener { + Log.d("MarkdownText", "onLongClick") preventLinkClick.value = true - onLongClick?.invoke() + if (onLongClick != null) { + onLongClick() + } true } } @@ -114,8 +113,7 @@ private fun createTextView( TextStyle( color = textColor, fontSize = if (fontSize != TextUnit.Unspecified) fontSize else style.fontSize, - textAlign = textAlign, - + textAlign = textAlign ?: style.textAlign ) ) return TextView(context).apply { @@ -147,7 +145,7 @@ private fun createTextView( private fun createMarkdownRender( context: Context, - onLinkClicked: ((String) -> Unit)? = null, + onLinkClicked: (() -> Unit)? = null, preventLinkClick: MutableState, ): Markwon { return Markwon.builder(context) diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownTextV2.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownTextV2.kt new file mode 100644 index 0000000..16565ea --- /dev/null +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownTextV2.kt @@ -0,0 +1,306 @@ +package com.troplo.privateuploader.components.chat + +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp + +// Regex containing the syntax tokens +val symbolPattern by lazy { + Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""") +} + +// Accepted annotations for the ClickableTextWrapper +enum class SymbolAnnotationType { + USER, LINK, COLLECTION +} + +typealias StringAnnotation = AnnotatedString.Range +// Pair returning styled content and annotation for ClickableText when matching syntax token +typealias SymbolAnnotation = Pair + +@Composable +fun MarkdownTextV2(modifier: Modifier = Modifier, content: String, color: Color = LocalContentColor.current, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null) { + val context = LocalContext.current + val styledMessage = ProcessMessageText( + messageText = content + ) + + ClickableText( + text = styledMessage, + style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { + if(onLongClick != null) { + onLongClick() + } + }, + onTap = { + + } + ) + }.then(modifier), + onClick = { + if(onClick == null) { + styledMessage + .getStringAnnotations(start = it, end = it) + .firstOrNull() + ?.let { annotation -> + when (annotation.tag) { + SymbolAnnotationType.LINK.name -> { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + + SymbolAnnotationType.USER.name -> { + Log.d("MarkdownText", "User annotation clicked") + } + + else -> Unit + } + } + } else { + onClick() + } + } + ) +} + +@Composable +fun c( + text: String +): AnnotatedString { + val tokens = symbolPattern.findAll(text) + + return buildAnnotatedString { + + var cursorPosition = 0 + + val codeSnippetBackground = MaterialTheme.colorScheme.surface + + for (token in tokens) { + append(text.slice(cursorPosition until token.range.first)) + + val (annotatedString, stringAnnotation) = getSymbolAnnotation( + matchResult = token, + colorScheme = MaterialTheme.colorScheme, + codeSnippetBackground = codeSnippetBackground + ) + append(annotatedString) + + if (stringAnnotation != null) { + val (item, start, end, tag) = stringAnnotation + addStringAnnotation(tag = tag, start = start, end = end, annotation = item) + } + + cursorPosition = token.range.last + 1 + } + + if (!tokens.none()) { + append(text.slice(cursorPosition..text.lastIndex)) + } else { + append(text) + } + } +} + +/** + * Map regex matches found in a message with supported syntax symbols + * + * @param matchResult is a regex result matching our syntax symbols + * @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper + */ +private fun getSymbolAnnotation( + matchResult: MatchResult, + colorScheme: ColorScheme, + codeSnippetBackground: Color +): SymbolAnnotation { + return when (matchResult.value.first()) { + '*' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('*'), + spanStyle = SpanStyle( + fontWeight = if (matchResult.value.length == 2) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (matchResult.value.length == 2) FontStyle.Normal else FontStyle.Italic + ) + ), + null + ) + '_' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('_'), + spanStyle = SpanStyle(fontStyle = FontStyle.Italic) + ), + null + ) + '~' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('~'), + spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough) + ), + null + ) + '`' -> { + val snippetText = matchResult.value.removeSurrounding("`") + SymbolAnnotation( + AnnotatedString( + text = snippetText, + spanStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + background = codeSnippetBackground, + baselineShift = BaselineShift(0.2f) + ) + ), + null + ) + } + '<' -> { + val numericalId = matchResult.value.substringAfter("<@").substringBefore(">") + Log.d("MarkdownText", "Numerical ID: $numericalId") + SymbolAnnotation( + AnnotatedString( + text = matchResult.value, + spanStyle = SpanStyle( + color = colorScheme.primary + ) + ), + StringAnnotation( + item = numericalId, + start = matchResult.range.first, + end = matchResult.range.last, + tag = SymbolAnnotationType.USER.name + ) + ) + } + 'h' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value, + spanStyle = SpanStyle( + color = colorScheme.primary + ) + ), + StringAnnotation( + item = matchResult.value, + start = matchResult.range.first, + end = matchResult.range.last, + tag = SymbolAnnotationType.LINK.name + ) + ) + else -> SymbolAnnotation(AnnotatedString(matchResult.value), null) + } +} + +@Composable +fun ProcessMessageText(messageText: String): AnnotatedString { + val hyperlinkRegex = "<@(\\d+)>".toRegex() + val italicRegex = "\\*(.*?)\\*".toRegex() + val boldRegex = "\\*\\*(.*?)\\*\\*".toRegex() + val strikethroughRegex = "~~(.*?)~~".toRegex() + + val matches = mutableListOf() + + matches.addAll(hyperlinkRegex.findAll(messageText)) + matches.addAll(italicRegex.findAll(messageText)) + matches.addAll(boldRegex.findAll(messageText)) + matches.addAll(strikethroughRegex.findAll(messageText)) + + matches.sortBy { it.range.first } + + var currentIndex = 0 + var processedText = "" + + matches.forEach { match -> + val startIndex = match.range.first + val endIndex = match.range.last + 1 + if(startIndex < currentIndex) return@forEach + // Append the text before the formatting tag + processedText += messageText.substring(currentIndex, startIndex) + + // Process the matched tag + val tag = match.value + val content = match.groupValues[1] + + processedText += when (tag) { + in hyperlinkRegex.pattern -> processHyperlink(content) + in italicRegex.pattern -> processItalic(content) + in boldRegex.pattern -> processBold(content) + in strikethroughRegex.pattern -> processStrikethrough(content) + else -> content // Should not reach here, but include for safety + } + + currentIndex = endIndex + } + + // Append the remaining text + processedText += messageText.substring(currentIndex) + + // Display the processed text + return buildAnnotatedString { append(processedText) } +} + +@Composable +fun processHyperlink(content: String): AnnotatedString { + val id = content.trim() + val username = "test" + + return buildAnnotatedString { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append(username) + } + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface)) { + append(" ") + append(id) + } + } +} + +@Composable +fun processItalic(content: String): AnnotatedString { + return buildAnnotatedString { + withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { + append(content) + } + } +} + +@Composable +fun processBold(content: String): AnnotatedString { + return buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(content) + } + } +} + +@Composable +fun processStrikethrough(content: String): AnnotatedString { + return buildAnnotatedString { + withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append(content) + } + } +} diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt index e0d1ea9..04dad92 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt @@ -1,5 +1,6 @@ package com.troplo.privateuploader.components.chat +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +14,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,9 +39,9 @@ import com.troplo.privateuploader.components.user.UserPopup fun MemberSidebar() { val chatActions = remember { mutableStateOf(false) } val chatId = ChatStore.associationId.collectAsState() - val chats = ChatStore.chats.collectAsState() + val chats = remember { ChatStore.chats } val chat = - remember { derivedStateOf { chats.value.find { it.association?.id == chatId.value } } } + remember { derivedStateOf { chats.find { it.association?.id == chatId.value } } } val user: MutableState = remember { mutableStateOf(null) } val popup = remember { mutableStateOf(false) } val pins = remember { mutableStateOf(false) } @@ -55,11 +58,14 @@ fun MemberSidebar() { PinsDialog(pins) } - Column { + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).fillMaxSize() + ) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) .fillMaxSize() ) { IconButton( @@ -90,7 +96,10 @@ fun MemberSidebar() { } } ListItem( - headlineContent = { Text("Members") } + headlineContent = { Text("Members") }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) ) if (chat.value != null) { @@ -109,7 +118,8 @@ fun MemberSidebar() { popup.value = true } }, - selected = false + selected = false, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) ) } } diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt index 2d953a7..cad289e 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt @@ -15,53 +15,40 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Reply import androidx.compose.material3.Divider -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.troplo.privateuploader.api.TpuFunctions import com.troplo.privateuploader.components.core.UserAvatar -import com.troplo.privateuploader.data.model.ChatAssociation import com.troplo.privateuploader.data.model.Embed import com.troplo.privateuploader.data.model.EmbedData import com.troplo.privateuploader.data.model.Message import com.troplo.privateuploader.data.model.defaultUser import com.troplo.privateuploader.ui.theme.PrivateUploaderTheme -@OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class) @Composable fun Message( + modifier: Modifier = Modifier, message: Message, compact: String = "none", - messageCtx: MutableState?, - messageCtxMessage: MutableState?, - modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, onReply: ((replyId: Int) -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { Column( modifier = Modifier .pointerInput(Unit) { detectTapGestures( - onLongPress = { - if (messageCtx == null || messageCtxMessage == null) return@detectTapGestures - messageCtxMessage.value = message - messageCtx.value = true - }, onTap = { if (onClick != null) onClick() } @@ -179,17 +166,13 @@ fun Message( modifier = Modifier.weight(1f).fillMaxWidth() ) { MarkdownText( - markdown = message.content, + content = message.content, color = color, - onLongClick = { - if (messageCtx == null || messageCtxMessage == null) return@MarkdownText - messageCtxMessage.value = message - messageCtx.value = true - }, onClick = { if (onClick != null) onClick() }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onLongClick = onLongClick ) if (message.edited && !normal) { val context = LocalContext.current @@ -229,11 +212,7 @@ fun Message( } } else { Box( - modifier = Modifier.padding(start = 4.dp).clickable { - if (messageCtx == null || messageCtxMessage == null) return@clickable - messageCtxMessage.value = message - messageCtx.value = true - } + modifier = Modifier.padding(start = 4.dp) ) { UserAvatar( avatar = null, @@ -246,6 +225,7 @@ fun Message( } } } + message.embeds.forEach { Embed(embed = it) } @@ -270,21 +250,7 @@ fun MessagePreview() { updatedAt = "2021-09-01T00:00:00.000Z", edited = true, editedAt = null, - embeds = listOf( - Embed( - data = EmbedData( - type = "image", - description = "yes", - height = 69, - siteName = "TPU", - title = "TPU", - upload = null, - url = "https://i.troplo.com", - width = 420 - ), - type = "image", - ) - ), + embeds = emptyList(), error = false, legacyUser = null, legacyUserId = null, @@ -300,7 +266,7 @@ fun MessagePreview() { updatedAt = "2021-09-01T00:00:00.000Z", edited = false, editedAt = null, - embeds = emptyList(), + embeds = emptyList(), error = false, legacyUser = null, legacyUserId = null, @@ -318,9 +284,7 @@ fun MessagePreview() { userId = 1, type = "message" ), - compact = "none", - messageCtx = null, - messageCtxMessage = null + compact = "none" ) } } diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MessageActions.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MessageActions.kt index b61722d..3db0549 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/MessageActions.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MessageActions.kt @@ -56,7 +56,7 @@ fun MessageActions( ModalBottomSheet( onDismissRequest = { openBottomSheet.value = false }, sheetState = bottomSheetState, - windowInsets = windowInsets +// windowInsets = windowInsets ) { if(!message.value?.readReceipts.isNullOrEmpty()) { val context = LocalContext.current @@ -85,7 +85,7 @@ fun MessageActions( } } if (message.value != null) { - Message(message.value!!, "none", null, null, modifier = Modifier.padding(bottom = 8.dp)) + Message(modifier = Modifier.padding(bottom = 8.dp), message.value!!, "none", null, null) } Column( modifier = Modifier.padding(bottom = 8.dp), diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/ReplyMessage.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/ReplyMessage.kt index 3074352..7d78b08 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/ReplyMessage.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/ReplyMessage.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -62,7 +63,7 @@ fun ReplyMessage(message: Message?, onReply: ((Int) -> Unit)? = null) { color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = 4.dp).fillMaxWidth() ) } else { Text( diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/MyDevice.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/MyDevice.kt index 91f2286..f2c8d57 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/MyDevice.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/MyDevice.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,6 +38,8 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.troplo.privateuploader.api.ChatStore +import com.troplo.privateuploader.api.stores.UploadStore +import com.troplo.privateuploader.components.core.InfiniteListHandler import com.troplo.privateuploader.data.model.UploadTarget @OptIn(ExperimentalPermissionsApi::class, ExperimentalLayoutApi::class) @@ -46,7 +49,7 @@ fun MyDevice() { val viewModel = remember { MyDeviceViewModel() } val context = LocalContext.current - val mediaPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val imagesPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { rememberPermissionState( android.Manifest.permission.READ_MEDIA_IMAGES ) @@ -56,9 +59,25 @@ fun MyDevice() { ) } - if (mediaPermissionState.status.isGranted) { + val filesPermissionState = rememberPermissionState( + android.Manifest.permission.READ_EXTERNAL_STORAGE + ) + + val lazyListState = remember { androidx.compose.foundation.lazy.LazyListState() } + + + InfiniteListHandler(listState = lazyListState) { + Log.d("TPU.MyDevice", "Load more images") + if (viewModel.offset.value == -1 || viewModel.loading.value) return@InfiniteListHandler viewModel.loadImages(context) - LazyColumn { + } + + + if (imagesPermissionState.status.isGranted) { + viewModel.loadImages(context) + LazyColumn( + state = lazyListState + ) { item { FlowRow( modifier = Modifier.fillMaxWidth() @@ -68,18 +87,22 @@ fun MyDevice() { modifier = Modifier.requiredHeight(120.dp).requiredWidth(120.dp) ) { UriPreview(upload, onClick = { - if (ChatStore.attachmentsToUpload.find { it.uri == upload.uri } != null) { + if (UploadStore.uploads.find { it.uri == upload.uri } != null) { Log.d( "MyDevice", "Removing ${upload.name} from attachments to upload." ) - ChatStore.attachmentsToUpload.removeIf { it.uri == upload.uri } + UploadStore.uploads.removeIf { it.uri == upload.uri } } else { Log.d( "MyDevice", "Adding ${upload.name} to attachments to upload." ) - ChatStore.attachmentsToUpload.add(upload) + UploadStore.uploads.add(upload) + Log.d( + "MyDevice", + "Uploads: ${UploadStore.uploads.map { it.name }}" + ) } }) } @@ -102,7 +125,7 @@ fun MyDevice() { horizontalAlignment = Alignment.CenterHorizontally ) { Text("This feature requires permission to access your local media files.") - Button(onClick = { mediaPermissionState.launchPermissionRequest() }) { + Button(onClick = { imagesPermissionState.launchPermissionRequest(); filesPermissionState.launchPermissionRequest() }) { Text("Request permission") } } @@ -111,8 +134,14 @@ fun MyDevice() { class MyDeviceViewModel : ViewModel() { val images = mutableStateListOf() + val offset = mutableStateOf(0) + val loading = mutableStateOf(false) fun loadImages(context: Context) { + if (loading.value || offset.value == -1) return + loading.value = true + val cachedOffset = offset.value + offset.value += 20 val contentResolver: ContentResolver = context.contentResolver val imagesUri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI @@ -120,16 +149,22 @@ class MyDeviceViewModel : ViewModel() { val projection = arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME) // Sorting by date modified in descending order - val sortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC" + val sortOrder = " LIMIT 20 OFFSET 0 ${MediaStore.Images.Media.DATE_MODIFIED} DESC" // Perform the query var cursor: Cursor? = null + try { val bundle = Bundle() bundle.putInt(ContentResolver.QUERY_ARG_LIMIT, 20) + bundle.putString(ContentResolver.QUERY_ARG_SORT_COLUMNS, MediaStore.Images.Media.DATE_MODIFIED) + bundle.putString(ContentResolver.QUERY_ARG_SORT_DIRECTION, "DESC") + print("Offset: $cachedOffset") + bundle.putInt(ContentResolver.QUERY_ARG_OFFSET, cachedOffset) cursor = contentResolver.query(imagesUri, projection, bundle, null) + cursor?.let { - while (cursor.moveToNext()) { + if (it.moveToFirst()) { // Retrieve the image ID val imageId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) @@ -137,21 +172,33 @@ class MyDeviceViewModel : ViewModel() { // Create the content URI for the image val imageUri = ContentUris.withAppendedId(imagesUri, imageId) - // Add the image URI to the list + // Add the image URI to the end of the list (newest first) Log.d("MediaStoreUtils", "Found image: $imageUri") + // add to start of list images.add( + 0, UploadTarget( uri = imageUri, name = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) ?: "unknown.file" ) ) + + Log.d("MediaStoreUtils", "Found ${cursor.count} images") + } else { + Log.d("MediaStoreUtils", "No images found") + offset.value = -1 } + + // Don't forget to close the cursor when you're done with it + it.close() + loading.value = false } + } catch (e: Exception) { Log.e("MediaStoreUtils", "Error retrieving images: ${e.message}") } finally { cursor?.close() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/UriPreview.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/UriPreview.kt index 6b39ac0..78f0c4c 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/UriPreview.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/attachment/UriPreview.kt @@ -23,13 +23,14 @@ import coil.request.ImageRequest import coil.size.Size import com.troplo.privateuploader.api.ChatStore import com.troplo.privateuploader.api.imageLoader +import com.troplo.privateuploader.api.stores.UploadStore import com.troplo.privateuploader.api.stores.UserStore import com.troplo.privateuploader.data.model.UploadTarget import kotlinx.coroutines.Dispatchers @Composable fun UriPreview(file: UploadTarget, onClick: () -> Unit) { - val store = ChatStore.attachmentsToUpload.find { it.uri == file.uri } + val store = UploadStore.uploads.find { it.uri == file.uri } Box( modifier = Modifier .fillMaxHeight() diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/ImageDialog.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/ImageDialog.kt index 22b1f99..f4123af 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/ImageDialog.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/ImageDialog.kt @@ -15,11 +15,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.size.Size +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.RequestOptions +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.glide.GlideImage import com.troplo.privateuploader.api.imageLoader import com.troplo.privateuploader.components.core.InteractionDialog import com.troplo.privateuploader.components.core.ZoomableBox @@ -34,24 +39,17 @@ fun ImageDialog(url: String, name: String, open: MutableState) { open = open, content = { ZoomableBox { - Image( - painter = rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current) - .dispatcher(Dispatchers.IO) - .data(data = url) - .apply(block = fun ImageRequest.Builder.() { - size(Size.ORIGINAL) - }).build(), imageLoader = imageLoader(LocalContext.current, false) + GlideImage( + imageModel = { url }, + imageOptions = ImageOptions( + contentScale = ContentScale.FillWidth + ), + modifier = Modifier.fillMaxSize().graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY ), - contentDescription = name, - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY - ) ) } }, diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/PinsDialog.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/PinsDialog.kt index 9430eb1..5ddd3cc 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/PinsDialog.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/PinsDialog.kt @@ -50,8 +50,8 @@ import kotlinx.coroutines.withContext @Composable fun PinsDialog(pins: MutableState) { val content = remember { mutableStateOf("") } - val chats = ChatStore.chats.collectAsState() - val chat = chats.value.find { it.association?.id == ChatStore.associationId.value } + val chats = remember { ChatStore.chats } + val chat = chats.find { it.association?.id == ChatStore.associationId.value } val viewModel = remember { PinsViewModel() } LaunchedEffect(Unit) { @@ -117,10 +117,9 @@ fun PinsDialog(pins: MutableState) { key = msg.id ) { Message( - msg, - "none", - null, - null, + message = msg, + compact = "none", + onReply = null, onClick = { pins.value = false ChatStore.jumpToMessage.value = msg.id diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/SearchDialog.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/SearchDialog.kt index 2b6dbc5..c76b418 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/SearchDialog.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/chat/dialogs/SearchDialog.kt @@ -47,8 +47,8 @@ import kotlinx.coroutines.withContext @Composable fun SearchDialog() { val content = remember { mutableStateOf("") } - val chats = ChatStore.chats.collectAsState() - val chat = chats.value.find { it.association?.id == ChatStore.associationId.value } + val chats = remember { ChatStore.chats } + val chat = chats.find { it.association?.id == ChatStore.associationId.value } val searchViewModel = remember { SearchViewModel() } val kbController = LocalSoftwareKeyboardController.current @@ -158,10 +158,9 @@ fun SearchDialog() { key = msg.id ) { Message( - msg, - "none", - null, - null, + message = msg, + compact = "none", + onReply = null, onClick = { ChatStore.searchPanel.value = false ChatStore.jumpToMessage.value = msg.id diff --git a/app/src/main/java/com/troplo/privateuploader/components/core/BottomNav.kt b/app/src/main/java/com/troplo/privateuploader/components/core/BottomNav.kt index 8b008d9..80416d3 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/core/BottomNav.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/core/BottomNav.kt @@ -75,7 +75,7 @@ fun BottomBarNav( } + fadeOut()) { NavigationBar( tonalElevation = if(isAMOLED.value) 0.dp else NavigationBarDefaults.Elevation, - modifier = Modifier.fillMaxWidth().height(56.dp) + modifier = Modifier.fillMaxWidth().height(82.dp) ) { Row( modifier = Modifier.padding(start = 8.dp, end = 8.dp) @@ -97,6 +97,7 @@ fun BottomBarNav( } } }, +// label = { Text("Chat") } // TO LOCALIZE //label = { Text("Chat") } ) @@ -118,7 +119,7 @@ fun BottomBarNav( } }, // TO LOCALIZE - //label = { Text("Gallery") } +// label = { Text("Gallery") } ) NavigationBarItem( @@ -148,7 +149,7 @@ fun BottomBarNav( } }, // TO LOCALIZE - //label = { Text("Friends") } +// label = { Text("Friends") } ) NavigationBarItem( @@ -178,7 +179,7 @@ fun BottomBarNav( } }, // TO LOCALIZE - //label = { Text("Notifications") } +// label = { Text("Notifications") } ) NavigationBarItem( @@ -200,7 +201,7 @@ fun BottomBarNav( } }, // TO LOCALIZE - //label = { Text("Settings") } +// label = { Text("Settings") } ) } } diff --git a/app/src/main/java/com/troplo/privateuploader/components/core/Connecting.kt b/app/src/main/java/com/troplo/privateuploader/components/core/Connecting.kt index 5802a5c..2b932b8 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/core/Connecting.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/core/Connecting.kt @@ -1,17 +1,25 @@ package com.troplo.privateuploader.components.core +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet4Bar import androidx.compose.material3.Card import androidx.compose.material3.Divider +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -30,7 +38,11 @@ fun ConnectingBanner() { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .height(116.dp) + .padding(horizontal = 16.dp) + .windowInsetsPadding(WindowInsets.statusBars), // Add padding for the status bar + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start ) { Icon( imageVector = Icons.Default.SignalCellularConnectedNoInternet4Bar, @@ -38,10 +50,10 @@ fun ConnectingBanner() { ) Text( text = "Connecting...", - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier.padding(start = 12.dp), + style = MaterialTheme.typography.bodyMedium ) } - - Divider() + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/troplo/privateuploader/components/core/HyperlinkText.kt b/app/src/main/java/com/troplo/privateuploader/components/core/HyperlinkText.kt index 96a2467..38a1919 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/core/HyperlinkText.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/core/HyperlinkText.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle @@ -34,6 +35,7 @@ fun HyperlinkText( addStyle( style = SpanStyle( color = linkTextColor, + fontSize = fontSize, fontWeight = linkTextFontWeight, textDecoration = linkTextDecoration @@ -51,7 +53,7 @@ fun HyperlinkText( addStyle( style = SpanStyle( fontSize = fontSize, - color = textStyle.color, + color = textStyle.color.takeOrElse { MaterialTheme.colorScheme.onBackground }, ), start = 0, end = fullText.length diff --git a/app/src/main/java/com/troplo/privateuploader/components/core/NavRoute.kt b/app/src/main/java/com/troplo/privateuploader/components/core/NavRoute.kt index 47f53b7..b71f706 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/core/NavRoute.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/core/NavRoute.kt @@ -12,7 +12,8 @@ fun getCurrentRouteTitle(route: String): String { NavRoute.SettingsPreferences.path -> "Preferences" NavRoute.Friends.path -> "Friends" NavRoute.SettingsCollections.path -> "Collections" - else -> "PrivateUploader" + NavRoute.Notifications.path -> "Notifications" + else -> "Flowinity" } } diff --git a/app/src/main/java/com/troplo/privateuploader/components/core/TopNav.kt b/app/src/main/java/com/troplo/privateuploader/components/core/TopNav.kt index ab46a03..35e15c7 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/core/TopNav.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/core/TopNav.kt @@ -38,9 +38,9 @@ fun TopBarNav(navController: NavController, openPanel: () -> Unit) { if (currentRoute == null || currentRoute == NavRoute.Login.path || currentRoute == NavRoute.Register.path) { return } - val chats = ChatStore.chats.collectAsState() + val chats = remember { ChatStore.chats } val chat = - remember { derivedStateOf { chats.value.find { it.association?.id == ChatStore.associationId.value } } } + remember { derivedStateOf { chats.find { it.association?.id == ChatStore.associationId.value } } } val user = UserStore.user.collectAsState() if (chatSearch.value) { diff --git a/app/src/main/java/com/troplo/privateuploader/components/core/UserAvatar.kt b/app/src/main/java/com/troplo/privateuploader/components/core/UserAvatar.kt index 51daf01..a81000e 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/core/UserAvatar.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/core/UserAvatar.kt @@ -1,5 +1,6 @@ package com.troplo.privateuploader.components.core +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -32,6 +33,9 @@ import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.size.Size +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.RequestOptions +import com.skydoves.landscapist.glide.GlideImage import com.troplo.privateuploader.api.TpuFunctions import com.troplo.privateuploader.api.imageLoader import com.troplo.privateuploader.api.stores.FriendStore @@ -94,19 +98,15 @@ fun UserAvatar( } if(fakeStatus != null) friend?.otherUser?.status = fakeStatus if (avatar != null) { - Image( - contentDescription = "User profile picture", - painter = rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current) - .dispatcher(Dispatchers.IO) - .data(data = TpuFunctions.image(avatar, null)) - .apply { - size(Size.ORIGINAL) - } - .build(), - imageLoader = imageLoader(LocalContext.current), - contentScale = ContentScale.FillWidth - ), + Log.d("UserAvatar", "avatar: ${ TpuFunctions.image(avatar, null, 512, 512)}") + GlideImage( + imageModel = { TpuFunctions.image(avatar, null, 512, 512) }, + requestOptions = { + RequestOptions() + .override(512, 512) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .centerCrop() + }, modifier = Modifier .clip(CircleShape) .background(if (fake) MaterialTheme.colorScheme.surface else Color.Transparent) diff --git a/app/src/main/java/com/troplo/privateuploader/components/gallery/GalleryItem.kt b/app/src/main/java/com/troplo/privateuploader/components/gallery/GalleryItem.kt index b8bfdba..ed9c972 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/gallery/GalleryItem.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/gallery/GalleryItem.kt @@ -53,7 +53,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.glide.GlideImage import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.api.TpuFunctions import com.troplo.privateuploader.api.imageLoader @@ -67,9 +68,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class, - ExperimentalLayoutApi::class -) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @Preview fun GalleryItem( @@ -127,18 +126,15 @@ fun GalleryItem( } else { TpuFunctions.image(item.attachment, null) } - Image( - contentDescription = item.name, - painter = rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current) - .data(data = url) - .apply(block = fun ImageRequest.Builder.() {}).build(), - imageLoader = imageLoader(LocalContext.current) + + GlideImage( + imageModel = { url }, + imageOptions = ImageOptions( + contentScale = ContentScale.Fit ), modifier = Modifier .requiredHeight(200.dp) .fillMaxWidth(), - contentScale = ContentScale.FillWidth ) } else { Icon( @@ -339,10 +335,10 @@ class SampleUploadProvider : PreviewParameterProvider { id = 1, name = "Test", attachment = "aaa.png", - type = "imagse", + type = "text", originalFilename = "Test.png", createdAt = "2021-08-01T00:00:00.000Z", - fileSize = 1000, + fileSize = 1024, collections = listOf( PartialCollection( id = 1, diff --git a/app/src/main/java/com/troplo/privateuploader/components/gallery/dialogs/AddToCollectionDialog.kt b/app/src/main/java/com/troplo/privateuploader/components/gallery/dialogs/AddToCollectionDialog.kt index ac94316..1151211 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/gallery/dialogs/AddToCollectionDialog.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/gallery/dialogs/AddToCollectionDialog.kt @@ -71,7 +71,7 @@ fun AddToCollectionDialog(open: MutableState, item: Upload) { expanded = expanded, onDismissRequest = { expanded = false }, ) { - options.value.forEach { collection -> + options.value.items.forEach { collection -> DropdownMenuItem( text = { Text(collection.name) }, onClick = { diff --git a/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/RenameCollectionDialog.kt b/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/RenameCollectionDialog.kt index 2dac809..b5c4c29 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/RenameCollectionDialog.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/RenameCollectionDialog.kt @@ -36,7 +36,7 @@ fun RenameCollectionDialog( collectionId: Int ) { val collections = CollectionStore.collections.collectAsState() - val collection = collections.value.find { it.id == collectionId } + val collection = collections.value.items.find { it.id == collectionId } val name = remember { mutableStateOf(collection?.name ?: "") } val viewModel = remember { RenameCollectionViewModel() } diff --git a/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/StatusDialog.kt b/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/StatusDialog.kt index 92f8540..acf0a9a 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/StatusDialog.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/settings/dialogs/StatusDialog.kt @@ -62,7 +62,7 @@ fun StatusDialog(open: MutableState = mutableStateOf(true)) { ModalBottomSheet( onDismissRequest = { open.value = false }, sheetState = bottomSheetState, - windowInsets = windowInsets, +// windowInsets = windowInsets, dragHandle = { }, ) { ListItem( diff --git a/app/src/main/java/com/troplo/privateuploader/components/user/UserBanner.kt b/app/src/main/java/com/troplo/privateuploader/components/user/UserBanner.kt index 979e835..2d6d461 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/user/UserBanner.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/user/UserBanner.kt @@ -19,14 +19,15 @@ import com.troplo.privateuploader.data.model.User @OptIn(ExperimentalGlideComposeApi::class) @Composable @Preview -fun UserBanner(banner: String? = UserStore.user.collectAsState().value?.banner) { +fun UserBanner(banner: String? = UserStore.user.collectAsState().value?.banner, modifier: Modifier = Modifier) { Box { GlideImage( model = if (banner != null) TpuFunctions.image(banner, null) else "https://i.troplo.com/i/a050d6f271c3.png", contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() - .height(200.dp), + .height(200.dp) + .then(modifier), contentDescription = null ) } diff --git a/app/src/main/java/com/troplo/privateuploader/components/user/UserPopup.kt b/app/src/main/java/com/troplo/privateuploader/components/user/UserPopup.kt index 6073c19..4da5629 100644 --- a/app/src/main/java/com/troplo/privateuploader/components/user/UserPopup.kt +++ b/app/src/main/java/com/troplo/privateuploader/components/user/UserPopup.kt @@ -88,7 +88,7 @@ fun UserPopup( ModalBottomSheet( onDismissRequest = { openBottomSheet.value = false }, sheetState = bottomSheetState, - windowInsets = windowInsets, +// windowInsets = windowInsets, dragHandle = { } ) { UserBanner(viewModel.user.value?.banner) diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Chat.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Chat.kt index b79135b..bee3bd5 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Chat.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Chat.kt @@ -1,10 +1,12 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import kotlinx.coroutines.flow.MutableStateFlow @JsonClass(generateAdapter = true) +@Keep data class Chat( @field:Json(name = "id") val id: Int, @field:Json(name = "name") val name: String, @@ -26,22 +28,26 @@ data class Chat( ) @JsonClass(generateAdapter = true) +@Keep data class ChatCreateRequest( @field:Json(name = "users") val users: List, ) @JsonClass(generateAdapter = true) +@Keep data class RemoveChatEvent( @field:Json(name = "id") val id: Int, ) @JsonClass(generateAdapter = true) +@Keep data class RemoveChatUserEvent( // this refers to the user's associationId @field:Json(name = "id") val id: Int, @field:Json(name = "chatId") val chatId: Int ) +@Keep @JsonClass(generateAdapter = true) data class AddChatUsersEvent( @field:Json(name = "chatId") val chatId: Int, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/ChatAssociation.kt b/app/src/main/java/com/troplo/privateuploader/data/model/ChatAssociation.kt index 5386f9d..cebe530 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/ChatAssociation.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/ChatAssociation.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class ChatAssociation( @field:Json(name = "id") val id: Int, @field:Json(name = "chatId") val chatId: Int, @@ -21,9 +23,10 @@ data class ChatAssociation( } @JsonClass(generateAdapter = true) +@Keep data class ReadReceiptEvent( @field:Json(name = "id") val id: Int, @field:Json(name = "chatId") val chatId: Int, @field:Json(name = "lastRead") val lastRead: Int, - @field:Json(name = "user") val user: PartialUser + @field:Json(name = "user") val user: PartialUserSocket ) \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Collection.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Collection.kt index 26cce93..dee517a 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Collection.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Collection.kt @@ -1,14 +1,18 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class PartialCollection( @Json(name = "id") val id: Int, @Json(name = "name") val name: String, ) +@JsonClass(generateAdapter = true) +@Keep data class Collection( @Json(name = "createdAt") val createdAt: String, @@ -41,6 +45,8 @@ data class Collection( val users: List ) +@JsonClass(generateAdapter = true) +@Keep data class PermissionsMetadata( @Json(name = "configure") val configure: Boolean, @@ -50,6 +56,9 @@ data class PermissionsMetadata( val write: Boolean ) + +@JsonClass(generateAdapter = true) +@Keep data class Preview( @Json(name = "attachment") val attachment: Attachment, @@ -71,6 +80,8 @@ data class Preview( val userId: Int ) +@JsonClass(generateAdapter = true) +@Keep data class Recipient( @Json(name = "accepted") val accepted: Boolean, @@ -123,6 +134,8 @@ data class CollectionUser( val write: Boolean ) +@JsonClass(generateAdapter = true) +@Keep data class Attachment( @Json(name = "attachment") val attachment: String, @@ -130,6 +143,8 @@ data class Attachment( val id: Int ) +@JsonClass(generateAdapter = true) +@Keep data class CollectivizeRequest( @Json(name = "attachmentId") val attachmentId: Int, @@ -137,11 +152,15 @@ data class CollectivizeRequest( val collectionId: Int ) +@JsonClass(generateAdapter = true) +@Keep data class CreateCollectionRequest( @Json(name = "name") val name: String ) +@JsonClass(generateAdapter = true) +@Keep data class ShareCollectionRequest( @Json(name = "id") val id: Int, @@ -149,11 +168,15 @@ data class ShareCollectionRequest( val type: String ) +@JsonClass(generateAdapter = true) +@Keep data class ShareCollectionResponse( @Json(name = "shareLink") val shareLink: String ) +@JsonClass(generateAdapter = true) +@Keep data class UpdateCollectionRequest( @Json(name = "name") val name: String diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/CollectionItem.kt b/app/src/main/java/com/troplo/privateuploader/data/model/CollectionItem.kt index bf6af23..04fdd1b 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/CollectionItem.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/CollectionItem.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class CollectionItem( @Json(name = "id") val id: Int, @Json(name = "collectionId") val collectionId: Int, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Error.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Error.kt index f9d430a..400f4f1 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Error.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Error.kt @@ -1,14 +1,17 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class ErrorResponse( @field:Json(name = "errors") val errors: List, ) @JsonClass(generateAdapter = true) +@Keep data class Error( @field:Json(name = "message") val message: String, @field:Json(name = "value") val value: String, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/FCM.kt b/app/src/main/java/com/troplo/privateuploader/data/model/FCM.kt index cfe0be0..972de4d 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/FCM.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/FCM.kt @@ -1,8 +1,10 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class FCMTokenRequest( val token: String, ) \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Friend.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Friend.kt index b7feca8..717c85d 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Friend.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Friend.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class Friend( @field:Json(name = "status") val status: String, @field:Json(name = "user") val user: PartialUser?, @@ -16,6 +18,7 @@ data class Friend( ) @JsonClass(generateAdapter = true) +@Keep data class FriendRequest( @Json(name = "friend") val friend: Friend?, @@ -26,6 +29,7 @@ data class FriendRequest( ) @JsonClass(generateAdapter = true) +@Keep data class FriendNicknameRequest( @Json(name = "nickname") val nickname: String diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Gallery.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Gallery.kt index 791a9b1..1241f90 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Gallery.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Gallery.kt @@ -1,15 +1,25 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class Gallery( @field:Json(name = "gallery") val gallery: List, @field:Json(name = "pager") val pager: Pager, ) @JsonClass(generateAdapter = true) +@Keep +data class Collections( + @field:Json(name = "items") val items: List, + @field:Json(name = "pager") val pager: Pager, +) + +@JsonClass(generateAdapter = true) +@Keep data class Pager( @field:Json(name = "currentPage") var currentPage: Int, @field:Json(name = "endIndex") val endIndex: Int, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/LoginRequest.kt b/app/src/main/java/com/troplo/privateuploader/data/model/LoginRequest.kt index c8cef50..5e662ef 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/LoginRequest.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/LoginRequest.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class LoginRequest( @field:Json(name = "email") val email: String, @field:Json(name = "password") val password: String, @@ -11,6 +13,7 @@ data class LoginRequest( ) @JsonClass(generateAdapter = true) +@Keep data class RegisterRequest( @field:Json(name = "username") val username: String, @field:Json(name = "password") val password: String, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/LoginResponse.kt b/app/src/main/java/com/troplo/privateuploader/data/model/LoginResponse.kt index d2970c5..0199334 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/LoginResponse.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/LoginResponse.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class LoginResponse( @field:Json(name = "token") val token: String, @field:Json(name = "user") val user: User, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Message.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Message.kt index d055b7d..a449978 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Message.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Message.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class Message( @field:Json(name = "id") val id: Int, @field:Json(name = "chatId") val chatId: Int, @@ -11,7 +13,7 @@ data class Message( @field:Json(name = "content") val content: String, // Type is null on Colubrina messages @field:Json(name = "type") val type: String?, - @field:Json(name = "embeds") var embeds: List, + @field:Json(name = "embeds") val embeds: List, @field:Json(name = "edited") val edited: Boolean, @field:Json(name = "editedAt") val editedAt: String?, @field:Json(name = "replyId") val replyId: Int?, @@ -30,26 +32,31 @@ data class Message( @JsonClass(generateAdapter = true) +@Keep data class MessageRequest( @field:Json(name = "content") val content: String, @field:Json(name = "attachments") val attachments: List = emptyList(), + @field:Json(name = "replyId") val replyId: Int? = null, ) @JsonClass(generateAdapter = true) +@Keep data class EditRequest( @field:Json(name = "content") val content: String, @field:Json(name = "id") val id: Int, ) @JsonClass(generateAdapter = true) +@Keep data class MessageEvent( @field:Json(name = "message") val message: Message, @field:Json(name = "mention") val mention: Boolean, @field:Json(name = "chat") val chat: Chat, - @field:Json(name = "association") val association: ChatAssociation, + @field:Json(name = "associationId") val associationId: Number, ) @JsonClass(generateAdapter = true) +@Keep data class MessageEventFirebase( @field:Json(name = "content") val content: String, @field:Json(name = "id") val id: Int, @@ -59,15 +66,18 @@ data class MessageEventFirebase( @field:Json(name = "avatar") val avatar: String, @field:Json(name = "chatName") val chatName: String, @field:Json(name = "createdAt") val createdAt: String, + @field:Json(name = "type") val type: String, ) @JsonClass(generateAdapter = true) +@Keep data class DeleteEvent( @field:Json(name = "chatId") val chatId: Int, @field:Json(name = "id") val id: Int, ) @JsonClass(generateAdapter = true) +@Keep data class EditEvent( @field:Json(name = "chatId") val chatId: Int, @field:Json(name = "id") val id: Int, @@ -78,27 +88,32 @@ data class EditEvent( @field:Json(name = "pinned") val pinned: Boolean, ) +@JsonClass(generateAdapter = true) +@Keep data class Embed( val type: String, val data: EmbedData?, ) +@JsonClass(generateAdapter = true) +@Keep data class EmbedData( val url: String?, val title: String?, val description: String?, val siteName: String?, - val width: Int?, - val height: Int?, + val width: Float?, + val height: Float?, val upload: Upload?, val type: String?, ) @JsonClass(generateAdapter = true) +@Keep data class EmbedResolutionEvent( @field:Json(name = "chatId") val chatId: Int, @field:Json(name = "id") val id: Int, - @field:Json(name = "embeds") val embeds: List, + @field:Json(name = "embeds") val embeds: List, ) data class EmbedFail( @@ -107,19 +122,87 @@ data class EmbedFail( ) @JsonClass(generateAdapter = true) +@Keep data class MessageSearchResponse( @field:Json(name = "messages") val messages: List, @field:Json(name = "pager") val pager: Pager, ) @JsonClass(generateAdapter = true) +@Keep data class MessagePaginate( @field:Json(name = "messages") val messages: List, @field:Json(name = "pager") val pager: Pager ) @JsonClass(generateAdapter = true) +@Keep data class PinRequest( @field:Json(name = "id") val id: Int, @field:Json(name = "pinned") val pinned: Boolean, +) + +// EmbedDataV2 +enum class EmbedMediaType { + IMAGE, + VIDEO, + AUDIO, + FILE +} + +enum class EmbedVersion { + COLUBRINA, + V1, + V2 +} + +@JsonClass(generateAdapter = true) +@Keep +data class EmbedMedia( + @Json(name = "url") val url: String?, + @Json(name = "proxyUrl") val proxyUrl: String?, + @Json(name = "attachment") val attachment: String?, + @Json(name = "width") val width: Int?, + @Json(name = "height") val height: Int?, + @Json(name = "isInternal") val isInternal: Boolean, + @Json(name = "upload") val upload: Upload?, + @Json(name = "mimeType") val mimeType: String?, + @Json(name = "type") val type: Int, + @Json(name = "videoEmbedUrl") val videoEmbedUrl: String? +) + +@JsonClass(generateAdapter = true) +@Keep +data class EmbedText( + @Json(name = "imageProxyUrl") val imageProxyUrl: String?, + @Json(name = "text") val text: String, + @Json(name = "heading") val heading: Boolean?, + @Json(name = "imageUrl") val imageUrl: String? +) + +enum class EmbedType { + REGULAR, + CHAT_INVITE, + DIRECT +} + +@JsonClass(generateAdapter = true) +@Keep +data class EmbedMetadata( + @Json(name = "url") val url: String?, + @Json(name = "siteName") val siteName: String?, + @Json(name = "siteIcon") val siteIcon: String?, + @Json(name = "footer") val footer: String?, + @Json(name = "type") val type: Int, + @Json(name = "id") val id: String?, + @Json(name = "restricted") val restricted: Boolean = false +) + +@JsonClass(generateAdapter = true) +@Keep +data class EmbedDataV2( + @Json(name = "media") val media: List?, + @Json(name = "text") val text: List?, + @Json(name = "metadata") val metadata: EmbedMetadata, + @Json(name = "version") val version: EmbedVersion = EmbedVersion.V2 ) \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/State.kt b/app/src/main/java/com/troplo/privateuploader/data/model/State.kt index 093ff17..b5d09a1 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/State.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/State.kt @@ -1,10 +1,12 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class State( @Json(name = "announcements") val announcements: List, @@ -25,7 +27,7 @@ data class State( @Json(name = "inviteAFriend") val inviteAFriend: Boolean, @Json(name = "maintenance") - val maintenance: Boolean, + val maintenance: Maintenance, @Json(name = "name") val name: String, @Json(name = "officialInstance") @@ -51,6 +53,7 @@ data class State( ) @JsonClass(generateAdapter = true) +@Keep data class Announcement( @Json(name = "content") val content: String, @@ -69,6 +72,7 @@ data class Announcement( ) @JsonClass(generateAdapter = true) +@Keep data class Connection( @Json(name = "ip") val ip: String, @@ -77,6 +81,7 @@ data class Connection( ) @JsonClass(generateAdapter = true) +@Keep data class Features( @Json(name = "autoCollects") val autoCollects: Boolean, @@ -91,6 +96,7 @@ data class Features( ) @JsonClass(generateAdapter = true) +@Keep data class Providers( @Json(name = "anilist") val anilist: Boolean, @@ -101,6 +107,7 @@ data class Providers( ) @JsonClass(generateAdapter = true) +@Keep data class Stats( @Json(name = "announcements") val announcements: Int, @@ -139,6 +146,7 @@ data class Stats( ) @JsonClass(generateAdapter = true) +@Keep data class MessageGraph( @Json(name = "data") val `data`: List, @@ -147,6 +155,7 @@ data class MessageGraph( ) @JsonClass(generateAdapter = true) +@Keep data class PulseGraph( @Json(name = "data") val `data`: List, @@ -155,9 +164,21 @@ data class PulseGraph( ) @JsonClass(generateAdapter = true) +@Keep data class UploadGraph( @Json(name = "data") val `data`: List, @Json(name = "labels") val labels: List, +) + +@JsonClass(generateAdapter = true) +@Keep +data class Maintenance( + @Json(name = "enabled") + val enabled: Boolean, + @Json(name = "message") + val message: String, + @Json(name = "statusPage") + val statusPage: String, ) \ No newline at end of file diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/TenorResponse.kt b/app/src/main/java/com/troplo/privateuploader/data/model/TenorResponse.kt index bb33f15..35b19fb 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/TenorResponse.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/TenorResponse.kt @@ -1,8 +1,10 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json +@Keep data class TenorResponse( @Json(name = "next") val next: String, @@ -10,6 +12,7 @@ data class TenorResponse( val results: List ) +@Keep data class Result( @Json(name = "content_description") val contentDescription: String, @@ -33,6 +36,7 @@ data class Result( val url: String ) +@Keep data class MediaFormats( @Json(name = "gif") val gif: MediaType, @@ -64,6 +68,7 @@ data class MediaFormats( val webm: MediaType ) +@Keep data class MediaType( @Json(name = "dims") val dims: List, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Typing.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Typing.kt index 1968339..4606368 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Typing.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Typing.kt @@ -1,9 +1,11 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) +@Keep data class Typing( @field:Json(name = "chatId") val chatId: Int, @field:Json(name = "userId") val userId: Int, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/Upload.kt b/app/src/main/java/com/troplo/privateuploader/data/model/Upload.kt index eeb51a7..9e1c735 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/Upload.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/Upload.kt @@ -1,10 +1,13 @@ package com.troplo.privateuploader.data.model import android.net.Uri +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.math.BigInteger @JsonClass(generateAdapter = true) +@Keep data class Upload( @Json(name = "id") val id: Int, @Json(name = "attachment") val attachment: String, @@ -13,7 +16,7 @@ data class Upload( @Json(name = "originalFilename") val originalFilename: String, @Json(name = "type") val type: String, @Json(name = "urlRedirect") val urlRedirect: String?, - @Json(name = "fileSize") val fileSize: Int, + @Json(name = "fileSize") val fileSize: Long, @Json(name = "deletable") val deletable: Boolean, @Json(name = "data") val data: Any?, @Json(name = "textMetadata") val textMetadata: String, @@ -25,12 +28,14 @@ data class Upload( ) @JsonClass(generateAdapter = true) +@Keep data class StarResponse( @Json(name = "status") val status: Boolean, @Json(name = "star") val star: Star?, ) @JsonClass(generateAdapter = true) +@Keep data class Star( @Json(name = "id") val id: Int, @Json(name = "userId") val userId: Int, @@ -40,6 +45,7 @@ data class Star( ) @JsonClass(generateAdapter = true) +@Keep data class UploadTarget( val uri: Uri, var progress: Float = 0f, @@ -49,6 +55,7 @@ data class UploadTarget( ) @JsonClass(generateAdapter = true) +@Keep data class UploadResponse( @Json(name = "url") val url: String, @Json(name = "upload") val upload: Upload, diff --git a/app/src/main/java/com/troplo/privateuploader/data/model/User.kt b/app/src/main/java/com/troplo/privateuploader/data/model/User.kt index eb73a67..573ad75 100644 --- a/app/src/main/java/com/troplo/privateuploader/data/model/User.kt +++ b/app/src/main/java/com/troplo/privateuploader/data/model/User.kt @@ -1,10 +1,12 @@ package com.troplo.privateuploader.data.model +import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.troplo.privateuploader.api.TpuFunctions @JsonClass(generateAdapter = true) +@Keep data class User( @field:Json(name = "id") val id: Int, @field:Json(name = "username") val username: String, @@ -53,9 +55,11 @@ data class User( @field:Json(name = "notifications") var notifications: List, @field:Json(name = "platforms") val platforms: List?, @field:Json(name = "nickname") val nickname: Nickname? = null, + @field:Json(name = "bot") val bot: Boolean? = null, ) @JsonClass(generateAdapter = true) +@Keep data class Platform( @field:Json(name = "platform") val platform: String, @field:Json(name = "id") val id: String, @@ -64,6 +68,7 @@ data class Platform( ) @JsonClass(generateAdapter = true) +@Keep data class Experiment( @field:Json(name = "id") val id: Int, @field:Json(name = "key") val key: String, @@ -74,6 +79,7 @@ data class Experiment( ) @JsonClass(generateAdapter = true) +@Keep data class Domain( @field:Json(name = "id") val id: Int, @field:Json(name = "domain") val domain: String, @@ -91,6 +97,7 @@ data class Domain( ) @JsonClass(generateAdapter = true) +@Keep data class Plan( @field:Json(name = "id") val id: Int, @field:Json(name = "name") val name: String, @@ -107,6 +114,7 @@ data class Plan( ) @JsonClass(generateAdapter = true) +@Keep data class Badge( @field:Json(name = "id") val id: Int, @field:Json(name = "name") val name: String, @@ -124,6 +132,7 @@ data class Badge( ) @JsonClass(generateAdapter = true) +@Keep data class BadgeAssociation( @field:Json(name = "badgeId") val badgeId: Int, @field:Json(name = "userId") val userId: Int, @@ -134,6 +143,7 @@ data class BadgeAssociation( ) @JsonClass(generateAdapter = true) +@Keep data class Notification( @field:Json(name = "id") val id: Int, @field:Json(name = "message") val message: String, @@ -264,6 +274,7 @@ fun defaultUser() = User( ) @JsonClass(generateAdapter = true) +@Keep data class StatusPayload( @field:Json(name = "status") val status: String?, @field:Json(name = "id") val id: Int, @@ -271,6 +282,7 @@ data class StatusPayload( ) @JsonClass(generateAdapter = true) +@Keep data class PartialUser( @field:Json(name = "avatar") val avatar: String?, @field:Json(name = "banner") val banner: String?, @@ -286,6 +298,15 @@ data class PartialUser( ) @JsonClass(generateAdapter = true) +@Keep +data class PartialUserSocket( + @field:Json(name = "avatar") val avatar: String?, + @field:Json(name = "id") val id: Int, + @field:Json(name = "username") val username: String, +) + +@JsonClass(generateAdapter = true) +@Keep data class Nickname( @field:Json(name = "id") val id: Int, @field:Json(name = "nickname") val nickname: String, @@ -336,6 +357,7 @@ fun defaultPartialUser( ) @JsonClass(generateAdapter = true) +@Keep data class SettingsPayload( @field:Json(name = "description") val description: String?, @field:Json(name = "discordPrecache") val discordPrecache: Boolean?, @@ -354,6 +376,7 @@ data class SettingsPayload( ) @JsonClass(generateAdapter = true) +@Keep data class PatchUser( @field:Json(name = "username") val username: String? = null, @field:Json(name = "description") val description: String? = null, diff --git a/app/src/main/java/com/troplo/privateuploader/screens/Chat.kt b/app/src/main/java/com/troplo/privateuploader/screens/Chat.kt index 25195e0..ad68f86 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/Chat.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/Chat.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.widget.Toast +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,6 +50,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization @@ -57,11 +59,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.gson.Gson +import com.troplo.privateuploader.MainActivity import com.troplo.privateuploader.api.ChatStore +import com.troplo.privateuploader.api.RequestBodyWithProgress import com.troplo.privateuploader.api.SessionManager import com.troplo.privateuploader.api.SocketHandler import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.api.TpuFunctions +import com.troplo.privateuploader.api.stores.UploadStore import com.troplo.privateuploader.api.stores.UserStore import com.troplo.privateuploader.components.chat.Attachment import com.troplo.privateuploader.components.chat.Message @@ -83,9 +88,11 @@ import com.troplo.privateuploader.data.model.MessageRequest import com.troplo.privateuploader.data.model.ReadReceiptEvent import com.troplo.privateuploader.data.model.UploadTarget import io.socket.emitter.Emitter +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.MultipartBody import org.json.JSONObject import retrofit2.HttpException import java.util.Date @@ -109,6 +116,7 @@ fun ChatScreen( val jumpToMessage = ChatStore.jumpToMessage.collectAsState() val attachment = remember { mutableStateOf(false) } val replyId: MutableState = remember { mutableIntStateOf(0) } + val uploads = remember { UploadStore.uploads } if(chatViewModel.loading.value && chatViewModel.messages.value == null) { Box( @@ -152,7 +160,7 @@ fun ChatScreen( .fillMaxWidth() ) { - if (ChatStore.attachmentsToUpload.size > 0) { + if (uploads.size > 0) { Card( modifier = Modifier .fillMaxWidth() @@ -166,12 +174,12 @@ fun ChatScreen( LazyRow( modifier = Modifier.fillMaxSize() ) { - ChatStore.attachmentsToUpload.forEach { + uploads.forEach { item( key = it.uri ) { UriPreview(it, onClick = { - ChatStore.attachmentsToUpload.remove(it) + UploadStore.uploads.remove(it) }) } } @@ -181,26 +189,30 @@ fun ChatScreen( if(replyId.value != 0) { val msg = chatViewModel.messages.value?.find { it.id == replyId.value } - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + Box( + modifier = Modifier.fillMaxWidth(), ) { - ReplyMessage(msg, null) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ReplyMessage(msg, null) + } IconButton( onClick = { replyId.value = 0 - } + }, + modifier = Modifier.align(Alignment.CenterEnd) ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close reply", - modifier = Modifier.padding(end = 16.dp, bottom = 4.dp) + contentDescription = "Cancel reply", ) } } } - if (editId.value != 0) { Row( modifier = Modifier @@ -272,18 +284,24 @@ fun ChatScreen( } }, trailingIcon = { + val enabled = remember(uploads, message.value, uploads) { + uploads.none { it.url == null } && (message.value.isNotEmpty() || uploads.isNotEmpty()) + } + IconButton( onClick = { chatViewModel.sendMessage( associationId, message.value, context, - editId.value + editId.value, + if(replyId.value != 0) replyId.value else null ) message.value = "" editId.value = 0 + replyId.value = 0 }, - enabled = ChatStore.attachmentsToUpload.none { it.url == null } && (message.value.isNotEmpty() || ChatStore.attachmentsToUpload.isNotEmpty()) + enabled = enabled ) { Icon( imageVector = Icons.Filled.Send, @@ -340,13 +358,23 @@ fun ChatScreen( key = msg.id ) { Message( - msg, - compact(msg, chatViewModel.messages.value!!), - messageCtx, - messageCtxMessage, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { + messageCtxMessage.value = msg + messageCtx.value = true + } + ) + }, + message = msg, + compact = compact(msg, chatViewModel.messages.value!!), onReply = { id -> ChatStore.jumpToMessage.value = id - } + }, + onLongClick = { + messageCtxMessage.value = msg + messageCtx.value = true + }, ) } } @@ -417,9 +445,12 @@ fun ChatScreen( } } - LaunchedEffect(ChatStore.attachmentsToUpload.size) { - ChatStore.attachmentsToUpload.forEach { file -> - if (file.started) return@LaunchedEffect + LaunchedEffect(UploadStore.uploads.size) { + Log.d("TPU.Upload", "Uploads changed, ${UploadStore.uploads.toList().toString()}}") + UploadStore.uploads.forEach { file -> + Log.d("TPU.Upload", "Checking file: $file") + if (file.started) return@forEach + Log.d("TPU.Upload", "Uploading file: $file") chatViewModel.uploadAttachment(file, context) } } @@ -445,23 +476,23 @@ class ChatViewModel : ViewModel() { socket?.off("message", msgListener) } - val msgListener: Emitter.Listener = Emitter.Listener { + private val msgListener: Emitter.Listener = Emitter.Listener { onMessage(it) } - val readReceiptListener: Emitter.Listener = Emitter.Listener { + private val readReceiptListener: Emitter.Listener = Emitter.Listener { onReadReceipt(it) } - val editListener: Emitter.Listener = Emitter.Listener { + private val editListener: Emitter.Listener = Emitter.Listener { onEdit(it) } - val messageDeleteListener: Emitter.Listener = Emitter.Listener { + private val messageDeleteListener: Emitter.Listener = Emitter.Listener { onMessageDelete(it) } - val embedResolutionListener: Emitter.Listener = Emitter.Listener { + private val embedResolutionListener: Emitter.Listener = Emitter.Listener { onEmbedResolution(it) } @@ -472,12 +503,15 @@ class ChatViewModel : ViewModel() { val payload = jsonArray.toString() val messageEvent = gson.fromJson(payload, MessageEvent::class.java) - val message = messageEvent.message + // Change in v4 uses uppercase type + val message = messageEvent.message.copy( + type = messageEvent.message.type?.lowercase() + ) - if (associationId != messageEvent.association.id) { + if (associationId != messageEvent.associationId.toInt()) { Log.d( - "TPU.Untagged", - "Message not for this association, ${messageEvent.association.id} != $associationId" + "Chat", + "Message not for this association, ${messageEvent.associationId} != $associationId ${associationId::class.java.typeName} ${messageEvent.associationId::class.java.typeName}" ) return } @@ -485,8 +519,8 @@ class ChatViewModel : ViewModel() { val existingMessage = messages.value?.find { e -> e.id == message.id || (e.content == message.content && e.pending == true && e.userId == message.userId) } Log.d( - "TPU.Untagged", - "Message for this association, ${messageEvent.association.id} == $associationId, $existingMessage" + "Chat", + "Message for this association, ${messageEvent.associationId} == $associationId, $existingMessage" ) if (existingMessage == null) { // add to start of list @@ -507,7 +541,7 @@ class ChatViewModel : ViewModel() { } fun onEmbedResolution(data: Array) { - val chatId = ChatStore.chats.value.find { it.association?.id == associationId }?.id + val chatId = ChatStore.chats.find { it.association?.id == associationId }?.id val jsonArray = data[0] as JSONObject val payload = jsonArray.toString() val embed = gson.fromJson(payload, EmbedResolutionEvent::class.java) @@ -624,6 +658,7 @@ class ChatViewModel : ViewModel() { } // find the old read receipt belonging to the user and delete it in messages[].readreceipts array + Log.d("ReadReceipts", "$readReceiptEvent") val index = messages.value.orEmpty() .indexOfFirst { e -> e.readReceipts.find { it.user.id == readReceiptEvent.user.id } != null } if (index != -1) { @@ -690,6 +725,7 @@ class ChatViewModel : ViewModel() { message: String, context: Context, editId: Int = 0, + replyId: Int? = null ) { viewModelScope.launch(Dispatchers.IO) { val user = UserStore.getUser() @@ -708,14 +744,14 @@ class ChatViewModel : ViewModel() { pending = true, edited = false, editedAt = null, - embeds = emptyList(), + embeds = emptyList(), error = false, legacyUser = null, legacyUserId = null, pinned = false, readReceipts = emptyList(), reply = null, - replyId = null, + replyId = replyId, tpuUser = user, type = "message" ) @@ -724,12 +760,13 @@ class ChatViewModel : ViewModel() { try { // ensure url is populated for attachments val uploadedAttachments = - ChatStore.attachmentsToUpload.filter { it.url != null }.map { it.url!! } - ChatStore.attachmentsToUpload = mutableStateListOf() + UploadStore.uploads.filter { it.url != null }.map { it.url!! } + UploadStore.uploads.clear() val response = TpuApi.retrofitService.sendMessage( associationId, MessageRequest( message, - attachments = uploadedAttachments + attachments = uploadedAttachments, + replyId = replyId ) ).execute() launch(Dispatchers.IO) { @@ -795,46 +832,10 @@ class ChatViewModel : ViewModel() { } fun uploadAttachment(file: UploadTarget, context: Context) { - //TODO: rewrite this to use the new upload endpoint - /* val converted = TpuFunctions.uriToFile(file.uri, context, file.name) - - viewModelScope.launch(Dispatchers.IO) { - val requestFile = RequestBodyWithProgress( - converted, - RequestBodyWithProgress.ContentType.PNG_IMAGE, - progressCallback = { progress -> - Log.d("TPU.Upload", "Progress: $progress") - ChatStore.attachmentsToUpload.find { it.uri == file.uri } - ?: return@RequestBodyWithProgress - ChatStore.attachmentsToUpload.removeIf { it.uri == file.uri } - ChatStore.attachmentsToUpload.add( - file.copy( - progress = progress, - started = true - ) - ) - }) - val body = MultipartBody.Part.createFormData("attachment", file.name, requestFile) - val response = TpuApi.retrofitService.uploadFile(body).execute() - withContext(Dispatchers.Main) { - if (response.isSuccessful) { - val attachment = response.body() - if (attachment != null) { - Log.d("TPU.Upload", "Success: ${attachment.url}") - ChatStore.attachmentsToUpload.find { it.uri == file.uri } - ?: return@withContext - ChatStore.attachmentsToUpload.removeIf { it.uri == file.uri } - ChatStore.attachmentsToUpload.add( - file.copy( - url = attachment.upload.attachment, - progress = 100f, - started = true - ) - ) - } - } - } - }*/ + UploadStore.uploads.find { it.uri == file.uri }?.started = true + Log.d("TPU.Chat", UploadStore.uploads.toList().toString()) + val list = listOf(file) + MainActivity().upload(list, false, context) } } diff --git a/app/src/main/java/com/troplo/privateuploader/screens/ChatHome.kt b/app/src/main/java/com/troplo/privateuploader/screens/ChatHome.kt index 961ce17..d0159df 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/ChatHome.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/ChatHome.kt @@ -1,16 +1,20 @@ package com.troplo.privateuploader.screens +import android.util.Log +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,11 +46,9 @@ fun HomeScreen( ) { val loading = remember { mutableStateOf(true) } val chatViewModel = remember { ChatHomeViewModel() } - val chatStore = ChatStore - val chats = chatStore.chats.collectAsState() + val chats = ChatStore.chats val createChat = remember { mutableStateOf(false) } - val chat = remember { mutableStateOf(null) } - + val listState = rememberLazyListState() LaunchedEffect(Unit) { chatViewModel.getChats().also { loading.value = false @@ -57,7 +59,14 @@ fun HomeScreen( NewChatDialog(createChat, navController) } - Column { + LaunchedEffect(chats) { + if (listState.firstVisibleItemIndex == 1) listState.scrollToItem(0) + } + + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { ListItem( headlineContent = { Text("Chats") @@ -70,14 +79,17 @@ fun HomeScreen( ) Box( modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) .fillMaxSize() ) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - chats.value.forEach { + LazyColumn(modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .fillMaxSize(), state = listState) { + chats.forEach { item( key = it.id ) { - ChatItem(it, openChat) + ChatItem(it, navController) } } } diff --git a/app/src/main/java/com/troplo/privateuploader/screens/Gallery.kt b/app/src/main/java/com/troplo/privateuploader/screens/Gallery.kt index 4835970..ee0222d 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/Gallery.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/Gallery.kt @@ -1,10 +1,19 @@ package com.troplo.privateuploader.screens import android.app.Activity +import android.app.PendingIntent +import android.content.ContentResolver import android.content.Context import android.content.ContextWrapper import android.content.Intent +import android.content.IntentSender +import android.net.Uri +import android.provider.OpenableColumns import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -54,6 +63,7 @@ import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat.startActivityForResult import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.troplo.privateuploader.MainActivity import com.troplo.privateuploader.api.SocketHandler import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.api.stores.CollectionStore @@ -67,6 +77,7 @@ import com.troplo.privateuploader.data.model.Pager import com.troplo.privateuploader.data.model.TenorResponse import com.troplo.privateuploader.data.model.Upload import com.troplo.privateuploader.data.model.UploadResponse +import com.troplo.privateuploader.data.model.UploadTarget import com.troplo.privateuploader.data.model.defaultUser import com.troplo.privateuploader.ui.theme.PrivateUploaderTheme import kotlinx.coroutines.Dispatchers @@ -75,7 +86,29 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import retrofit2.Response +import java.util.Date +public fun getFileName(uri: Uri, ctx: Context): String { + val contentResolver: ContentResolver = ctx.contentResolver + var result: String? = null + if (uri.scheme == "content") { + val cursor = contentResolver.query(uri, null, null, null, null) + cursor.use { c -> + if (c != null && c.moveToFirst()) { + val index = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + result = c.getString(index) + } + } + } + if (result == null) { + result = uri.path + val cut = result!!.lastIndexOf('/') + if (cut != -1) { + result = result!!.substring(cut + 1) + } + } + return result as String +} @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -86,10 +119,20 @@ fun GalleryScreen( inline: Boolean = false, onClick: (Upload) -> Unit = {}, ) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { data -> + Log.d("TPU.UploadResponse", "Upload response received, data: $data") + val files = mutableListOf() + for (uri in data) { + files.add(UploadTarget(uri = uri, name = getFileName(uri, context))) + } + MainActivity().upload(files, false, context) + } + + val galleryViewModel = remember { GalleryViewModel() } val searchState = remember { mutableStateOf(galleryViewModel.search) } val listState = rememberLazyListState() - val context = LocalContext.current val activity = LocalContext.current as Activity var expanded by remember { mutableStateOf(false) } var selectedCollectionText = remember { mutableStateOf("None") } @@ -202,7 +245,7 @@ fun GalleryScreen( contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, ) Divider() - collections.value.forEach { collection -> + collections.value.items.forEach { collection -> DropdownMenuItem( text = { Text(collection.name) }, onClick = { @@ -265,9 +308,7 @@ fun GalleryScreen( if(type != "tenor") { ExpandedFloatingActionButton( onClick = { - Log.d("GalleryScreen", activity.toString()) - if (activity == null) return@ExpandedFloatingActionButton - UploadStore.requestUploadIntent(activity) + launcher.launch(arrayOf("*/*")) }, extended = listState.isScrollingUp(), icon = { @@ -285,14 +326,17 @@ fun GalleryScreen( ) } + class GalleryViewModel : ViewModel() { val gallery = mutableStateOf(null) val search = mutableStateOf("") val loading = mutableStateOf(true) fun onMount() { - val socket = SocketHandler.getSocket() - socket?.on("gallery/create") { + val socket = SocketHandler.getGallerySocket() + socket?.off("create") + socket?.off("update") + socket?.on("create") { val jsonArray = it[0] as JSONArray val payload = jsonArray.toString() val uploads = SocketHandler.gson.fromJson(payload, Array::class.java).toList() @@ -305,16 +349,26 @@ class GalleryViewModel : ViewModel() { } } - socket?.on("gallery/update") { + socket?.on("update") { val jsonArray = it[0] as JSONArray val payload = jsonArray.toString() val uploads = SocketHandler.gson.fromJson(payload, Array::class.java).toList() - if(search.value.isEmpty()) { + + if (search.value.isEmpty()) { Log.d("GalleryViewModel", "Received upload: $uploads") - // replace the existing uploads if they exist with new data + + // Update the existing gallery with new data based on matching IDs gallery.value = gallery.value?.copy( - gallery = gallery.value?.gallery?.map { upload -> - uploads.find { it.id == upload.id } ?: upload + gallery = gallery.value?.gallery?.map { existingUpload -> + uploads.find { it.id == existingUpload.id }?.let { newUpload -> + // Create a new upload by spreading the fields of the existing upload + existingUpload.copy( + name = newUpload.name ?: existingUpload.name, + collections = newUpload.collections ?: existingUpload.collections, + starred = newUpload.starred ?: existingUpload.starred, + textMetadata = newUpload.textMetadata ?: existingUpload.textMetadata + ) + } ?: existingUpload // If no match found, keep the existing upload }.orEmpty() ) } @@ -323,8 +377,8 @@ class GalleryViewModel : ViewModel() { fun onStop() { Log.d("GalleryViewModel", "onStop") - SocketHandler.getSocket()?.off("gallery/create") - SocketHandler.getSocket()?.off("gallery/update") + SocketHandler.getGallerySocket()?.off("create") + SocketHandler.getGallerySocket()?.off("update") } fun getGalleryItems(t: String = "gallery", collectionId: Int = 0) { diff --git a/app/src/main/java/com/troplo/privateuploader/screens/Login.kt b/app/src/main/java/com/troplo/privateuploader/screens/Login.kt index 593bd37..abd6370 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/Login.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/Login.kt @@ -1,6 +1,7 @@ package com.troplo.privateuploader.screens import android.content.Context +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -23,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -30,10 +34,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.troplo.privateuploader.R import com.troplo.privateuploader.api.SessionManager import com.troplo.privateuploader.api.SocketHandler import com.troplo.privateuploader.api.SocketHandlerService import com.troplo.privateuploader.api.TpuApi +import com.troplo.privateuploader.api.stores.CoreStore import com.troplo.privateuploader.api.stores.UserStore import com.troplo.privateuploader.components.core.LoadingButton import com.troplo.privateuploader.data.model.LoginRequest @@ -50,98 +56,103 @@ fun LoginScreen(onLoginSuccess: () -> Unit, navigate: (String) -> Unit) { val context = LocalContext.current val viewModel = remember { LoginViewModel() } - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "PrivateUploader", - style = MaterialTheme.typography.displayMedium, - color = Primary, - modifier = Modifier.padding(top = 8.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - val instanceState = remember { mutableStateOf(SessionManager(context).getInstanceURL()) } - LaunchedEffect(instanceState.value) { - delay(500) - viewModel.checkInstance(instanceState.value, context) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - TextField( - value = instanceState.value, - onValueChange = { instanceState.value = it }, - label = { Text("PrivateUploader Instance") }, - supportingText = { Text(viewModel.instanceVersion.value) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(32.dp)) - val usernameState = remember { mutableStateOf("") } - TextField( - value = usernameState.value, - onValueChange = { usernameState.value = it }, - label = { Text("Username") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(16.dp)) - val passwordState = remember { mutableStateOf("") } - TextField( - value = passwordState.value, - onValueChange = { passwordState.value = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(16.dp)) - val totpState = remember { mutableStateOf("") } - TextField( - value = totpState.value, - onValueChange = { totpState.value = it }, - label = { Text("2FA code (if enabled)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(16.dp)) - LoadingButton( - onClick = { - viewModel.login( - username = usernameState.value, - password = passwordState.value, - totp = totpState.value, - context = context, - onLoginSuccess = onLoginSuccess - ) - }, - loading = viewModel.loading, - text = "Login", - enabled = usernameState.value.isNotEmpty() && passwordState.value.isNotEmpty(), - modifier = Modifier.fillMaxWidth() + item { + Image( + painter = painterResource(id = R.drawable.flowinity_full), + contentDescription = "Flowinity Logo", + modifier = Modifier + .width(250.dp), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.onSurface) ) - Box( + + Spacer(modifier = Modifier.height(8.dp)) + val instanceState = + remember { mutableStateOf(SessionManager(context).getInstanceURL()) } + LaunchedEffect(instanceState.value) { + delay(500) + viewModel.checkInstance(instanceState.value, context) + } + + Column( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp) + .padding(16.dp) ) { - ClickableText( - text = AnnotatedString("Don't have an account?"), - style = TextStyle( - color = Primary, - fontSize = MaterialTheme.typography.bodyMedium.fontSize - ), - modifier = Modifier.align(Alignment.CenterEnd), + TextField( + value = instanceState.value, + onValueChange = { instanceState.value = it }, + label = { Text("Flowinity Instance") }, + supportingText = { Text(viewModel.instanceVersion.value) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(32.dp)) + val usernameState = remember { mutableStateOf("") } + TextField( + value = usernameState.value, + onValueChange = { usernameState.value = it }, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + val passwordState = remember { mutableStateOf("") } + TextField( + value = passwordState.value, + onValueChange = { passwordState.value = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + val totpState = remember { mutableStateOf("") } + TextField( + value = totpState.value, + onValueChange = { totpState.value = it }, + label = { Text("2FA code (if enabled)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + LoadingButton( onClick = { - navigate("register") - } + viewModel.login( + username = usernameState.value, + password = passwordState.value, + totp = totpState.value, + context = context, + onLoginSuccess = onLoginSuccess + ) + }, + loading = viewModel.loading, + text = "Login", + enabled = usernameState.value.isNotEmpty() && passwordState.value.isNotEmpty(), + modifier = Modifier.fillMaxWidth() ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + ClickableText( + text = AnnotatedString("Don't have an account?"), + style = TextStyle( + color = Primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize + ), + modifier = Modifier.align(Alignment.CenterEnd), + onClick = { + navigate("register") + } + ) + } } } } @@ -195,6 +206,7 @@ class LoginViewModel : ViewModel() { SessionManager(context).saveAuthToken(token) // go to the main screen TpuApi.init(data.body()!!.token, context) + CoreStore.initializeCore() SocketHandler.closeSocket() SocketHandler.initializeSocket(token, context) UserStore.initializeUser(context) diff --git a/app/src/main/java/com/troplo/privateuploader/screens/Notifications.kt b/app/src/main/java/com/troplo/privateuploader/screens/Notifications.kt index 1d363e6..74d9a5c 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/Notifications.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/Notifications.kt @@ -1,5 +1,7 @@ package com.troplo.privateuploader.screens +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd @@ -13,6 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.troplo.privateuploader.api.TpuApi @@ -43,7 +47,9 @@ fun NotificationsScreen() { viewModel.markAsRead() } - LazyColumn { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { user.value?.notifications?.forEach { notification -> item( key = notification.id diff --git a/app/src/main/java/com/troplo/privateuploader/screens/Register.kt b/app/src/main/java/com/troplo/privateuploader/screens/Register.kt index ff043a7..59b64a9 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/Register.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/Register.kt @@ -2,6 +2,7 @@ package com.troplo.privateuploader.screens import android.content.Context import android.widget.Toast +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -9,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -23,7 +26,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -31,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.troplo.privateuploader.R import com.troplo.privateuploader.api.SessionManager import com.troplo.privateuploader.api.SocketHandler import com.troplo.privateuploader.api.SocketHandlerService @@ -53,107 +59,112 @@ fun RegisterScreen(onLoginSuccess: () -> Unit, navigate: (String) -> Unit) { val context = LocalContext.current val viewModel = remember { RegisterViewModel() } - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "PrivateUploader", - style = MaterialTheme.typography.displayMedium, - color = Primary, - modifier = Modifier.padding(top = 8.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - val instanceState = remember { mutableStateOf(SessionManager(context).getInstanceURL()) } - LaunchedEffect(instanceState.value) { - delay(500) - viewModel.checkInstance(instanceState.value, context) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - TextField( - value = instanceState.value, - onValueChange = { instanceState.value = it }, - label = { Text("PrivateUploader Instance") }, - supportingText = { Text(viewModel.instanceVersion.value) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(32.dp)) - val usernameState = remember { mutableStateOf("") } - TextField( - value = usernameState.value, - onValueChange = { usernameState.value = it }, - label = { Text("Username") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(16.dp)) - val emailState = remember { mutableStateOf("") } - TextField( - value = emailState.value, - onValueChange = { emailState.value = it }, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Spacer(modifier = Modifier.height(16.dp)) - val passwordState = remember { mutableStateOf("") } - TextField( - value = passwordState.value, - onValueChange = { passwordState.value = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - HyperlinkText( - modifier = Modifier.padding(top = 8.dp), - linkTextColor = Primary, - fullText = "By registering I agree to the Privacy Policy, and Content Policy.", - hyperLinks = mutableMapOf( - "Privacy Policy" to "https://privateuploader.com/policies/privacy", - "Content Policy" to "https://privateuploader.com/policies/content" - ), - ) - Spacer(modifier = Modifier.height(16.dp)) - LoadingButton( - onClick = { - viewModel.register( - username = usernameState.value, - password = passwordState.value, - email = emailState.value, - context = context, - onLoginSuccess = onLoginSuccess - ) - }, - loading = viewModel.loading, - text = "Register", - enabled = usernameState.value.isNotEmpty() && passwordState.value.isNotEmpty(), - modifier = Modifier.fillMaxWidth() + item { + Image( + painter = painterResource(id = R.drawable.flowinity_full), + contentDescription = "Flowinity Logo", + modifier = Modifier + .width(250.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) ) - Box( + + Spacer(modifier = Modifier.height(8.dp)) + val instanceState = + remember { mutableStateOf(SessionManager(context).getInstanceURL()) } + LaunchedEffect(instanceState.value) { + delay(500) + viewModel.checkInstance(instanceState.value, context) + } + + Column( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp) + .padding(16.dp) ) { - ClickableText( - text = AnnotatedString("Already have an account?"), - style = TextStyle( - color = Primary, - fontSize = MaterialTheme.typography.bodyMedium.fontSize - ), - modifier = Modifier.align(Alignment.CenterEnd), + TextField( + value = instanceState.value, + onValueChange = { instanceState.value = it }, + label = { Text("Flowinity Instance") }, + supportingText = { Text(viewModel.instanceVersion.value) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(32.dp)) + val usernameState = remember { mutableStateOf("") } + TextField( + value = usernameState.value, + onValueChange = { usernameState.value = it }, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + val emailState = remember { mutableStateOf("") } + TextField( + value = emailState.value, + onValueChange = { emailState.value = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + val passwordState = remember { mutableStateOf("") } + TextField( + value = passwordState.value, + onValueChange = { passwordState.value = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + HyperlinkText( + modifier = Modifier.padding(top = 8.dp), + linkTextColor = Primary, + fullText = "By registering I agree to the Privacy Policy, and Content Policy.", + hyperLinks = mutableMapOf( + "Privacy Policy" to "https://privateuploader.com/policies/privacy", + "Content Policy" to "https://privateuploader.com/policies/content" + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + LoadingButton( onClick = { - navigate("login") - } + viewModel.register( + username = usernameState.value, + password = passwordState.value, + email = emailState.value, + context = context, + onLoginSuccess = onLoginSuccess + ) + }, + loading = viewModel.loading, + text = "Register", + enabled = usernameState.value.isNotEmpty() && passwordState.value.isNotEmpty(), + modifier = Modifier.fillMaxWidth() ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + ClickableText( + text = AnnotatedString("Already have an account?"), + style = TextStyle( + color = Primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize + ), + modifier = Modifier.align(Alignment.CenterEnd), + onClick = { + navigate("login") + } + ) + } } } } diff --git a/app/src/main/java/com/troplo/privateuploader/screens/settings/Settings.kt b/app/src/main/java/com/troplo/privateuploader/screens/settings/Settings.kt index b2545db..cefe731 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/settings/Settings.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/settings/Settings.kt @@ -3,6 +3,7 @@ package com.troplo.privateuploader.screens.settings import android.content.Intent import android.net.Uri import android.widget.Toast +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBox @@ -41,12 +43,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.troplo.privateuploader.BuildConfig +import com.troplo.privateuploader.R import com.troplo.privateuploader.api.SessionManager import com.troplo.privateuploader.api.TpuApi import com.troplo.privateuploader.api.stores.UserStore @@ -85,9 +90,6 @@ fun SettingsScreen( } Scaffold( modifier = Modifier.fillMaxSize(), - topBar = { - UserBanner() - }, content = { paddingValues -> Column( modifier = Modifier.padding(top = paddingValues.calculateTopPadding() + 8.dp) @@ -97,6 +99,9 @@ fun SettingsScreen( .fillMaxSize() ) { item { + UserBanner( + modifier = Modifier.padding(bottom = 8.dp) + ) SettingsItem( content = { Row( @@ -138,7 +143,7 @@ fun SettingsScreen( item { SettingsItem( Icons.Default.AccountBox, - "My TPU", + "My Flowinity", "Change your username, email, and password.", onClick = { navigate("settings/account") }) } @@ -156,7 +161,7 @@ fun SettingsScreen( SettingsItem( Icons.Default.Upload, "Auto-Upload", - "Options to automatically upload to TPU.", + "Options to automatically upload to Flowinity.", onClick = { navigate("settings/upload") } ) } @@ -175,7 +180,7 @@ fun SettingsScreen( SettingsItem( Icons.Default.MoreTime, "Changelog", - "Recent updates to PrivateUploader Mobile.", + "Recent updates to Flowinity Mobile.", onClick = { navigate("settings/changelog") } ) } @@ -184,11 +189,11 @@ fun SettingsScreen( SettingsItem( Icons.Default.OpenInBrowser, "Can't find what you're looking for?", - "Visit PrivateUploader on the web.", + "Visit Flowinity on the web.", onClick = { val intent = Intent( Intent.ACTION_VIEW, - Uri.parse("https://privateuploader.com") + Uri.parse("https://flowinity.com") ) context.startActivity(intent) } @@ -199,7 +204,7 @@ fun SettingsScreen( SettingsItem( Icons.Default.Logout, "Logout", - "Logout of TPU.", + "Logout of Flowinity.", onClick = { logout.value = true } @@ -211,7 +216,7 @@ fun SettingsScreen( SettingsItem( Icons.Default.DeviceUnknown, "Re-attempt device registration", - "Re-attempt device registration with TPU Firebase CM", + "Re-attempt device registration with Flowinity Firebase CM", onClick = { UserStore.registerFCMToken() } @@ -236,36 +241,29 @@ fun SettingsScreen( ) { val rippled: MutableState = remember { mutableIntStateOf(0) } - Text( - text = "Troplo", - style = MaterialTheme.typography.bodyLarge, - color = Primary, + Image( + painter = painterResource(id = R.drawable.flowinity_full), + contentDescription = "Flowinity Logo", modifier = Modifier - .align(Alignment.CenterHorizontally) - .offset(y = (6).dp) - ) - Text( - text = "PrivateUploader", - style = MaterialTheme.typography.displayMedium, - color = Primary, - modifier = Modifier.clickable { - rippled.value += 1 - if (rippled.value > 5) { - SessionManager(context).setDebugMode(!UserStore.debug) - UserStore.debug = !UserStore.debug - Toast.makeText( - context, - "Debug mode: ${UserStore.debug}", - Toast.LENGTH_SHORT - ).show() - rippled.value = 0 - } - } + .width(250.dp).clickable { + rippled.value += 1 + if (rippled.value > 5) { + SessionManager(context).setDebugMode(!UserStore.debug) + UserStore.debug = !UserStore.debug + Toast.makeText( + context, + "Debug mode: ${UserStore.debug}", + Toast.LENGTH_SHORT + ).show() + rippled.value = 0 + } + }, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) ) Text( text = "Mobile Early Access", - style = MaterialTheme.typography.bodyLarge, - color = Primary + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Divider( modifier = Modifier @@ -287,7 +285,7 @@ fun SettingsScreen( }", style = MaterialTheme.typography.bodySmall ) Text( - text = "TPU Server: ${ + text = "Flowinity Server: ${ TpuApi.instance }", style = MaterialTheme.typography.bodySmall ) @@ -297,7 +295,7 @@ fun SettingsScreen( }", style = MaterialTheme.typography.bodySmall ) Text( - text = "Build time: ${ + text = "Build date: ${ BuildConfig.BUILD_TIME }", style = MaterialTheme.typography.bodySmall ) diff --git a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsAccount.kt b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsAccount.kt index b21893d..61c729c 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsAccount.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsAccount.kt @@ -37,7 +37,7 @@ fun SettingsAccountScreen() { LazyColumn( modifier = Modifier .fillMaxSize() - .padding(top = 8.dp) +// .padding(top = 64.dp) ) { item { SettingsItem( @@ -75,7 +75,7 @@ fun SettingsAccountScreen() { SettingsItem( Icons.Default.Password, "Change password", - "Change your TPU password", + "Change your Flowinity password", trailingContent = { Icon(Icons.Default.ChevronRight, contentDescription = "Change password") }, diff --git a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsChangelog.kt b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsChangelog.kt index bd9b6e8..92b4b1e 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsChangelog.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsChangelog.kt @@ -67,7 +67,7 @@ fun ChangelogSection(title: String, content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) +// .padding(vertical = 64.dp) ) { Text( text = title, diff --git a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollectionItem.kt b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollectionItem.kt index 2863550..f2c52e9 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollectionItem.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollectionItem.kt @@ -44,7 +44,7 @@ fun SettingsCollectionItemScreen(id: Int? = 0, navigate: (String) -> Unit = {}) Box(modifier = Modifier.fillMaxSize()) { val viewModel = remember { SettingsCollectionItemViewModel() } val collections = CollectionStore.collections.collectAsState() - val collection = remember { mutableStateOf(collections.value.find { it.id == id }) } + val collection = remember { mutableStateOf(collections.value.items.find { it.id == id }) } val privacyOption: MutableState = remember { mutableIntStateOf( if(collection.value?.shareLink != null) 1 else 0 )} @@ -151,7 +151,7 @@ fun SettingsCollectionItemScreen(id: Int? = 0, navigate: (String) -> Unit = {}) contentColor = MaterialTheme.colorScheme.onErrorContainer ) ) - Text("More Collection settings are coming soon to PrivateUploader Mobile! You can see more settings on the web app.", modifier = Modifier.padding(8.dp)) + Text("More Collection settings are coming soon to Flowinity Mobile! You can see more settings on the web app.", modifier = Modifier.padding(8.dp)) } } } diff --git a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollections.kt b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollections.kt index 02fca24..24133ed 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollections.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsCollections.kt @@ -33,7 +33,6 @@ fun SettingsCollectionsScreen(navigate: (String) -> Unit = {}) { LazyColumn( modifier = Modifier .fillMaxSize() - .padding(top = 8.dp) ) { item { SettingsItem( @@ -52,7 +51,7 @@ fun SettingsCollectionsScreen(navigate: (String) -> Unit = {}) { ) } - collections.value.forEach { collection -> + collections.value.items.forEach { collection -> item( key = collection.id ) { diff --git a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsPreferences.kt b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsPreferences.kt index e2306a0..a19c40e 100644 --- a/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsPreferences.kt +++ b/app/src/main/java/com/troplo/privateuploader/screens/settings/SettingsPreferences.kt @@ -73,7 +73,6 @@ fun SettingsPreferencesScreen() { LazyColumn( modifier = Modifier .fillMaxSize() - .padding(top = 8.dp) ) { item { // Theme switcher diff --git a/app/src/main/java/com/troplo/privateuploader/ui/theme/Theme.kt b/app/src/main/java/com/troplo/privateuploader/ui/theme/Theme.kt index 992470c..d34ecbc 100644 --- a/app/src/main/java/com/troplo/privateuploader/ui/theme/Theme.kt +++ b/app/src/main/java/com/troplo/privateuploader/ui/theme/Theme.kt @@ -76,6 +76,7 @@ fun PrivateUploaderTheme( background = Color(0xFF000000), surface = Color(0xFF000000), surfaceVariant = Color(0xFF0F1015), + surfaceContainer = Color(0xFF000000), error = Red ) } else { diff --git a/app/src/main/res/drawable/flowinity_full.xml b/app/src/main/res/drawable/flowinity_full.xml new file mode 100644 index 0000000..77fba7e --- /dev/null +++ b/app/src/main/res/drawable/flowinity_full.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/flowinity_logo.xml b/app/src/main/res/drawable/flowinity_logo.xml new file mode 100644 index 0000000..571a295 --- /dev/null +++ b/app/src/main/res/drawable/flowinity_logo.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..0e918b2 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,20 @@ - - - - - - - - + android:viewportWidth="472" + android:viewportHeight="472"> + - \ No newline at end of file + android:pathData="M235.55,188.81L188.4,235.96L235.6,283.16L282.75,236.01L235.55,188.81Z" + android:fillColor="#ffffff"/> + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..f5f71ae --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 036d09b..ef49c99 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 036d09b..ef49c99 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index bde4195..c4d0bcf 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 4ecabc4..bc54c27 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 69fefbb..c44e291 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index e7534e3..fae9c49 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 1968f51..e43dc1a 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 2bd376d..c9289d3 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 04faf24..190ccdb 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 2cae1c2..a33addb 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 09048ea..6760ccf 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 712d902..26dbe75 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..c1509c8 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82f5884..0b9193e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - PrivateUploader + Flowinity \ No newline at end of file diff --git a/app/src/main/tpu.svg b/app/src/main/tpu.svg new file mode 100644 index 0000000..e69de29 diff --git a/build.gradle.kts b/build.gradle.kts index c07c05f..f6af17d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,5 +4,9 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.kotlinAndroid) apply false id("com.google.gms.google-services") version "4.3.15" apply false + alias(libs.plugins.androidTest) apply false + id("se.patrikerdes.use-latest-versions") version "0.2.18" + id("com.github.ben-manes.versions") version "0.41.0" + id("nl.littlerobots.version-catalog-update") version "0.8.5" } true // Needed to make the Suppress annotation work for the plugins block \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b900d2..fd8b27b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,37 +1,42 @@ [versions] -agp = "8.2.0-alpha10" -kotlin = "1.8.10" -core-ktx = "1.9.0" +activity-compose = "1.10.0-alpha03" +agp = "8.12.2" +androidx-test-ext-junit = "1.2.1" +appcompat = "1.7.0" +benchmark-macro-junit4 = "1.4.0-alpha04" +compose-bom = "2024.10.01" +core-ktx = "1.15.0" +core-splashscreen = "1.2.0-alpha02" +espresso-core = "3.6.1" junit = "4.13.2" -androidx-test-ext-junit = "1.1.5" -espresso-core = "3.5.1" -lifecycle-runtime-ktx = "2.6.1" -activity-compose = "1.7.2" -compose-bom = "2023.03.00" -appcompat = "1.6.1" -work-runtime-ktx = "2.8.1" +kotlin = "2.2.10" +lifecycle-runtime-ktx = "2.9.0-alpha06" +uiautomator = "2.4.0-alpha01" +work-runtime-ktx = "2.10.0" [libraries] -core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } -espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } -lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } -activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } -compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } -ui = { group = "androidx.compose.ui", name = "ui" } -ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime-ktx" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +androidx-activity = { module = "androidx.activity:activity", version.ref = "activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "core-splashscreen" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime-ktx" } +benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark-macro-junit4" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" } +junit = { module = "junit:junit", version.ref = "junit" } +lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } +material3 = { module = "androidx.compose.material3:material3" } +ui = { module = "androidx.compose.ui:ui" } +ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } +androidTest = { id = "com.android.test", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } - -[bundles] - diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 48000ee..b40e214 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Jun 23 17:55:51 AEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 9f94f91..716d78e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ dependencyResolutionManagement { rootProject.name = "PrivateUploader" include(":app") +include(":app:benchmark")