diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cecc1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..7b1619a --- /dev/null +++ b/README.MD @@ -0,0 +1,58 @@ +![](docs/images/logo.png) + +[![Downloads](https://img.shields.io/badge/dynamic/json?color=green&label=Downloads&query=totalString&url=https%3A%2F%2Fraw.githubusercontent.com%2Ftopjohnwu%2Fmagisk-files%2Fcount%2Fcount.json&cacheSeconds=1800)](https://raw.githubusercontent.com/topjohnwu/magisk-files/count/count.json) + +#### This is not an officially supported Google product + +## Introduction + +Magisk is a suite of open source software for customizing Android, supporting devices higher than Android 6.0.
+Some highlight features: + +- **MagiskSU**: Provide root access for applications +- **Magisk Modules**: Modify read-only partitions by installing modules +- **MagiskBoot**: The most complete tool for unpacking and repacking Android boot images +- **Zygisk**: Run code in every Android applications' processes + +## Downloads + +[Github](https://github.com/topjohnwu/Magisk/releases) is the only source where you can get official Magisk information and downloads. + +## Useful Links + +- [Installation Instruction](https://topjohnwu.github.io/Magisk/install.html) +- [Building and Development](https://topjohnwu.github.io/Magisk/build.html) +- [Magisk Documentation](https://topjohnwu.github.io/Magisk/) +- [Zygisk module sample](https://github.com/topjohnwu/zygisk-module-sample) + +## Bug Reports + +**Only bug reports from Debug builds will be accepted.** + +For installation issues, upload both boot image and install logs.
+For Magisk issues, upload boot logcat or dmesg.
+For Magisk app crashes, record and upload the logcat when the crash occurs. + +## Translation Contributions + +Default string resources for the Magisk app and its stub APK are located here: + +- `app/core/src/main/res/values/strings.xml` +- `app/stub/src/main/res/values/strings.xml` + +Translate each and place them in the respective locations (`[module]/src/main/res/values-[lang]/strings.xml`). + +## License + + Magisk, including all git submodules are free software: + you can redistribute it and/or modify it under the terms of the + GNU General Public License as published by the Free Software Foundation, + either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..39b258f --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,7 @@ +/dict.txt + +# Gradle +.gradle +.kotlin +/local.properties +/build diff --git a/app/apk/.gitignore b/app/apk/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/apk/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/apk/build.gradle.kts b/app/apk/build.gradle.kts new file mode 100644 index 0000000..3e6b329 --- /dev/null +++ b/app/apk/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + id("com.android.application") + kotlin("android") + kotlin("plugin.parcelize") + kotlin("kapt") + id("androidx.navigation.safeargs.kotlin") +} + +setupMainApk() + +kapt { + correctErrorTypes = true + useBuildCache = true + mapDiagnosticLocations = true + javacOptions { + option("-Xmaxerrs", "1000") + } +} + +android { + buildFeatures { + dataBinding = true + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + } + } +} + +dependencies { + implementation(project(":core")) + coreLibraryDesugaring(libs.jdk.libs) + + implementation(libs.indeterminate.checkbox) + implementation(libs.rikka.layoutinflater) + implementation(libs.rikka.insets) + implementation(libs.rikka.recyclerview) + + implementation(libs.navigation.fragment.ktx) + implementation(libs.navigation.ui.ktx) + + implementation(libs.constraintlayout) + implementation(libs.swiperefreshlayout) + implementation(libs.recyclerview) + implementation(libs.transition) + implementation(libs.fragment.ktx) + implementation(libs.appcompat) + implementation(libs.material) + + // Make sure kapt runs with a proper kotlin-stdlib + kapt(kotlin("stdlib")) +} diff --git a/app/apk/src/main/AndroidManifest.xml b/app/apk/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a55c4e5 --- /dev/null +++ b/app/apk/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/AsyncLoadViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/AsyncLoadViewModel.kt new file mode 100644 index 0000000..c712c82 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/AsyncLoadViewModel.kt @@ -0,0 +1,22 @@ +package com.topjohnwu.magisk.arch + +import androidx.annotation.MainThread +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +abstract class AsyncLoadViewModel : BaseViewModel() { + + private var loadingJob: Job? = null + + @MainThread + fun startLoading() { + if (loadingJob?.isActive == true) { + // Prevent multiple jobs from running at the same time + return + } + loadingJob = viewModelScope.launch { doLoadWork() } + } + + protected abstract suspend fun doLoadWork() +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/BaseFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/BaseFragment.kt new file mode 100644 index 0000000..526c7c0 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/BaseFragment.kt @@ -0,0 +1,96 @@ +package com.topjohnwu.magisk.arch + +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.MenuProvider +import androidx.databinding.DataBindingUtil +import androidx.databinding.OnRebindCallback +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavDirections +import com.topjohnwu.magisk.BR + +abstract class BaseFragment : Fragment(), ViewModelHolder { + + val activity get() = getActivity() as? NavigationActivity<*> + protected lateinit var binding: Binding + protected abstract val layoutRes: Int + + private val navigation get() = activity?.navigation + open val snackbarView: View? get() = null + open val snackbarAnchorView: View? get() = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + startObserveLiveData() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = DataBindingUtil.inflate(inflater, layoutRes, container, false).also { + it.setVariable(BR.viewModel, viewModel) + it.lifecycleOwner = viewLifecycleOwner + } + if (this is MenuProvider) { + activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.STARTED) + } + savedInstanceState?.let { viewModel.onRestoreState(it) } + return binding.root + } + + override fun onSaveInstanceState(outState: Bundle) { + viewModel.onSaveState(outState) + } + + override fun onStart() { + super.onStart() + activity?.supportActionBar?.subtitle = null + } + + override fun onEventDispatched(event: ViewEvent) = when(event) { + is ContextExecutor -> event(requireContext()) + is ActivityExecutor -> activity?.let { event(it) } ?: Unit + is FragmentExecutor -> event(this) + else -> Unit + } + + open fun onKeyEvent(event: KeyEvent): Boolean { + return false + } + + open fun onBackPressed(): Boolean = false + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.addOnRebindCallback(object : OnRebindCallback() { + override fun onPreBind(binding: Binding): Boolean { + this@BaseFragment.onPreBind(binding) + return true + } + }) + } + + override fun onResume() { + super.onResume() + viewModel.let { + if (it is AsyncLoadViewModel) + it.startLoading() + } + } + + protected open fun onPreBind(binding: Binding) { + (binding.root as? ViewGroup)?.startAnimations() + } + + fun NavDirections.navigate() { + navigation?.currentDestination?.getAction(actionId)?.let { navigation!!.navigate(this) } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/BaseViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/BaseViewModel.kt new file mode 100644 index 0000000..601777f --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/BaseViewModel.kt @@ -0,0 +1,83 @@ +package com.topjohnwu.magisk.arch + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.Manifest.permission.REQUEST_INSTALL_PACKAGES +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.databinding.PropertyChangeRegistry +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.navigation.NavDirections +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.databinding.ObservableHost +import com.topjohnwu.magisk.events.BackPressEvent +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.events.DialogEvent +import com.topjohnwu.magisk.events.NavigationEvent +import com.topjohnwu.magisk.events.PermissionEvent +import com.topjohnwu.magisk.events.SnackbarEvent + +abstract class BaseViewModel : ViewModel(), ObservableHost { + + override var callbacks: PropertyChangeRegistry? = null + + private val _viewEvents = MutableLiveData() + val viewEvents: LiveData get() = _viewEvents + + open fun onSaveState(state: Bundle) {} + open fun onRestoreState(state: Bundle) {} + open fun onNetworkChanged(network: Boolean) {} + + fun withPermission(permission: String, callback: (Boolean) -> Unit) { + PermissionEvent(permission, callback).publish() + } + + inline fun withExternalRW(crossinline callback: () -> Unit) { + withPermission(WRITE_EXTERNAL_STORAGE) { + if (!it) { + SnackbarEvent(R.string.external_rw_permission_denied).publish() + } else { + callback() + } + } + } + + @SuppressLint("InlinedApi") + inline fun withInstallPermission(crossinline callback: () -> Unit) { + withPermission(REQUEST_INSTALL_PACKAGES) { + if (!it) { + SnackbarEvent(R.string.install_unknown_denied).publish() + } else { + callback() + } + } + } + + @SuppressLint("InlinedApi") + inline fun withPostNotificationPermission(crossinline callback: () -> Unit) { + withPermission(POST_NOTIFICATIONS) { + if (!it) { + SnackbarEvent(R.string.post_notifications_denied).publish() + } else { + callback() + } + } + } + + fun back() = BackPressEvent().publish() + + fun ViewEvent.publish() { + _viewEvents.postValue(this) + } + + fun DialogBuilder.show() { + DialogEvent(this).publish() + } + + fun NavDirections.navigate(pop: Boolean = false) { + _viewEvents.postValue(NavigationEvent(this, pop)) + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/NavigationActivity.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/NavigationActivity.kt new file mode 100644 index 0000000..090b3c2 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/NavigationActivity.kt @@ -0,0 +1,50 @@ +package com.topjohnwu.magisk.arch + +import android.content.ContentResolver +import android.view.KeyEvent +import androidx.databinding.ViewDataBinding +import androidx.navigation.NavController +import androidx.navigation.NavDirections +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.navOptions +import com.topjohnwu.magisk.utils.AccessibilityUtils + +abstract class NavigationActivity : UIActivity() { + + abstract val navHostId: Int + + private val navHostFragment by lazy { + supportFragmentManager.findFragmentById(navHostId) as NavHostFragment + } + + protected val currentFragment get() = + navHostFragment.childFragmentManager.fragments.getOrNull(0) as? BaseFragment<*> + + val navigation: NavController get() = navHostFragment.navController + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return if (binded && currentFragment?.onKeyEvent(event) == true) true else super.dispatchKeyEvent(event) + } + + override fun onBackPressed() { + if (binded) { + if (currentFragment?.onBackPressed() == false) { + super.onBackPressed() + } + } + } + + companion object { + fun navigate(directions: NavDirections, navigation: NavController, cr: ContentResolver) { + if (AccessibilityUtils.isAnimationEnabled(cr)) { + navigation.navigate(directions) + } else { + navigation.navigate(directions, navOptions {}) + } + } + } + + fun NavDirections.navigate() { + navigate(this, navigation, contentResolver) + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/UIActivity.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/UIActivity.kt new file mode 100644 index 0000000..f91d782 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/UIActivity.kt @@ -0,0 +1,141 @@ +package com.topjohnwu.magisk.arch + +import android.content.Context +import android.content.res.Resources +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.res.use +import androidx.core.view.WindowCompat +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import androidx.transition.AutoTransition +import androidx.transition.TransitionManager +import com.google.android.material.snackbar.Snackbar +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.base.ActivityExtension +import com.topjohnwu.magisk.core.base.IActivityExtension +import com.topjohnwu.magisk.core.isRunningAsStub +import com.topjohnwu.magisk.core.ktx.reflectField +import com.topjohnwu.magisk.core.wrap +import rikka.insets.WindowInsetsHelper +import rikka.layoutinflater.view.LayoutInflaterFactory + +abstract class UIActivity + : AppCompatActivity(), ViewModelHolder, IActivityExtension { + + protected lateinit var binding: Binding + protected abstract val layoutRes: Int + override val extension = ActivityExtension(this) + + protected val binded get() = ::binding.isInitialized + + open val snackbarView get() = binding.root + open val snackbarAnchorView: View? get() = null + + init { + AppCompatDelegate.setDefaultNightMode(Config.darkTheme) + } + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base.wrap()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + layoutInflater.factory2 = LayoutInflaterFactory(delegate) + .addOnViewCreatedListener(WindowInsetsHelper.LISTENER) + + extension.onCreate(savedInstanceState) + if (isRunningAsStub) { + // Overwrite private members to avoid nasty "false" stack traces being logged + val delegate = delegate + val clz = delegate.javaClass + clz.reflectField("mActivityHandlesConfigFlagsChecked").set(delegate, true) + clz.reflectField("mActivityHandlesConfigFlags").set(delegate, 0) + } + + super.onCreate(savedInstanceState) + + startObserveLiveData() + + // We need to set the window background explicitly since for whatever reason it's not + // propagated upstream + obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground)) + .use { it.getDrawable(0) } + .also { window.setBackgroundDrawable(it) } + + WindowCompat.setDecorFitsSystemWindows(window, false) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + window?.decorView?.post { + // If navigation bar is short enough (gesture navigation enabled), make it transparent + if ((window.decorView.rootWindowInsets?.systemWindowInsetBottom + ?: 0) < Resources.getSystem().displayMetrics.density * 40) { + window.navigationBarColor = Color.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.navigationBarDividerColor = Color.TRANSPARENT + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + extension.onSaveInstanceState(outState) + } + + fun setContentView() { + binding = DataBindingUtil.setContentView(this, layoutRes).also { + it.setVariable(BR.viewModel, viewModel) + it.lifecycleOwner = this + } + } + + fun setAccessibilityDelegate(delegate: View.AccessibilityDelegate?) { + binding.root.rootView.accessibilityDelegate = delegate + } + + fun showSnackbar( + message: CharSequence, + length: Int = Snackbar.LENGTH_SHORT, + builder: Snackbar.() -> Unit = {} + ) = Snackbar.make(snackbarView, message, length) + .setAnchorView(snackbarAnchorView).apply(builder).show() + + override fun onResume() { + super.onResume() + viewModel.let { + if (it is AsyncLoadViewModel) + it.startLoading() + } + } + + override fun onEventDispatched(event: ViewEvent) = when (event) { + is ContextExecutor -> event(this) + is ActivityExecutor -> event(this) + else -> Unit + } +} + +fun ViewGroup.startAnimations() { + val transition = AutoTransition() + .setInterpolator(FastOutSlowInInterpolator()) + .setDuration(400) + .excludeTarget(R.id.main_toolbar, true) + TransitionManager.beginDelayedTransition( + this, + transition + ) +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt new file mode 100644 index 0000000..1db4c5d --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt @@ -0,0 +1,21 @@ +package com.topjohnwu.magisk.arch + +import android.content.Context + +/** + * Class for passing events from ViewModels to Activities/Fragments + * (see https://medium.com/google-developers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150) + */ +abstract class ViewEvent + +interface ContextExecutor { + operator fun invoke(context: Context) +} + +interface ActivityExecutor { + operator fun invoke(activity: UIActivity<*>) +} + +interface FragmentExecutor { + operator fun invoke(fragment: BaseFragment<*>) +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewModelHolder.kt b/app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewModelHolder.kt new file mode 100644 index 0000000..1ada2e5 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewModelHolder.kt @@ -0,0 +1,49 @@ +package com.topjohnwu.magisk.arch + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.di.ServiceLocator +import com.topjohnwu.magisk.ui.home.HomeViewModel +import com.topjohnwu.magisk.ui.install.InstallViewModel +import com.topjohnwu.magisk.ui.log.LogViewModel +import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel +import com.topjohnwu.magisk.ui.surequest.SuRequestViewModel + +interface ViewModelHolder : LifecycleOwner, ViewModelStoreOwner { + + val viewModel: BaseViewModel + + fun startObserveLiveData() { + viewModel.viewEvents.observe(this, this::onEventDispatched) + Info.isConnected.observe(this, viewModel::onNetworkChanged) + } + + /** + * Called for all [ViewEvent]s published by associated viewModel. + */ + fun onEventDispatched(event: ViewEvent) {} +} + +object VMFactory : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return when (modelClass) { + HomeViewModel::class.java -> HomeViewModel(ServiceLocator.networkService) + LogViewModel::class.java -> LogViewModel(ServiceLocator.logRepo) + SuperuserViewModel::class.java -> SuperuserViewModel(ServiceLocator.policyDB) + InstallViewModel::class.java -> + InstallViewModel(ServiceLocator.networkService, ServiceLocator.markwon) + SuRequestViewModel::class.java -> + SuRequestViewModel(ServiceLocator.policyDB, ServiceLocator.timeoutPrefs) + else -> modelClass.newInstance() + } as T + } +} + +inline fun ViewModelHolder.viewModel() = + lazy(LazyThreadSafetyMode.NONE) { + ViewModelProvider(this, VMFactory)[VM::class.java] + } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/databinding/DataBindingAdapters.kt b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/DataBindingAdapters.kt new file mode 100644 index 0000000..9cc6084 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/DataBindingAdapters.kt @@ -0,0 +1,346 @@ +package com.topjohnwu.magisk.databinding + +import android.animation.ValueAnimator +import android.content.res.ColorStateList +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.text.Spanned +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.Spinner +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.Toolbar +import androidx.cardview.widget.CardView +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.updateLayoutParams +import androidx.core.widget.ImageViewCompat +import androidx.databinding.BindingAdapter +import androidx.databinding.InverseBindingAdapter +import androidx.databinding.InverseBindingListener +import androidx.databinding.InverseMethod +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip +import com.google.android.material.slider.Slider +import com.google.android.material.textfield.TextInputLayout +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.di.ServiceLocator +import com.topjohnwu.magisk.core.model.su.SuPolicy +import com.topjohnwu.magisk.utils.TextHolder +import com.topjohnwu.superuser.internal.UiThreadHandler +import com.topjohnwu.widget.IndeterminateCheckBox +import kotlin.math.roundToInt + +@BindingAdapter("gone") +fun setGone(view: View, gone: Boolean) { + view.isGone = gone +} + +@BindingAdapter("invisible") +fun setInvisible(view: View, invisible: Boolean) { + view.isInvisible = invisible +} + +@BindingAdapter("goneUnless") +fun setGoneUnless(view: View, goneUnless: Boolean) { + setGone(view, goneUnless.not()) +} + +@BindingAdapter("invisibleUnless") +fun setInvisibleUnless(view: View, invisibleUnless: Boolean) { + setInvisible(view, invisibleUnless.not()) +} + +@BindingAdapter("markdownText") +fun setMarkdownText(tv: TextView, markdown: Spanned) { + ServiceLocator.markwon.setParsedMarkdown(tv, markdown) +} + +@BindingAdapter("onNavigationClick") +fun setOnNavigationClickedListener(view: Toolbar, listener: View.OnClickListener) { + view.setNavigationOnClickListener(listener) +} + +@BindingAdapter("srcCompat") +fun setImageResource(view: ImageView, @DrawableRes resId: Int) { + view.setImageResource(resId) +} + +@BindingAdapter("srcCompat") +fun setImageResource(view: ImageView, drawable: Drawable) { + view.setImageDrawable(drawable) +} + +@BindingAdapter("onTouch") +fun setOnTouchListener(view: View, listener: View.OnTouchListener) { + view.setOnTouchListener(listener) +} + +@BindingAdapter("scrollToLast") +fun setScrollToLast(view: RecyclerView, shouldScrollToLast: Boolean) { + + fun scrollToLast() = UiThreadHandler.handler.postDelayed({ + view.scrollToPosition(view.adapter?.itemCount?.minus(1) ?: 0) + }, 30) + + fun wait(callback: () -> Unit) { + UiThreadHandler.handler.postDelayed(callback, 1000) + } + + fun RecyclerView.Adapter<*>.setListener() { + val observer = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + scrollToLast() + } + } + registerAdapterDataObserver(observer) + view.setTag(R.id.recyclerScrollListener, observer) + } + + fun RecyclerView.Adapter<*>.removeListener() { + val observer = + view.getTag(R.id.recyclerScrollListener) as? RecyclerView.AdapterDataObserver ?: return + unregisterAdapterDataObserver(observer) + } + + fun trySetListener(): Unit = view.adapter?.setListener() ?: wait { trySetListener() } + + if (shouldScrollToLast) { + trySetListener() + } else { + view.adapter?.removeListener() + } +} + +@BindingAdapter("isEnabled") +fun setEnabled(view: View, isEnabled: Boolean) { + view.isEnabled = isEnabled +} + +@BindingAdapter("error") +fun TextInputLayout.setErrorString(error: String) { + val newError = error.let { if (it.isEmpty()) null else it } + if (this.error == null && newError == null) return + this.error = newError +} + +// md2 + +@BindingAdapter( + "android:layout_marginLeft", + "android:layout_marginTop", + "android:layout_marginRight", + "android:layout_marginBottom", + "android:layout_marginStart", + "android:layout_marginEnd", + requireAll = false +) +fun View.setMargins( + marginLeft: Int?, + marginTop: Int?, + marginRight: Int?, + marginBottom: Int?, + marginStart: Int?, + marginEnd: Int? +) = updateLayoutParams { + marginLeft?.let { leftMargin = it } + marginTop?.let { topMargin = it } + marginRight?.let { rightMargin = it } + marginBottom?.let { bottomMargin = it } + marginStart?.let { this.marginStart = it } + marginEnd?.let { this.marginEnd = it } +} + +@BindingAdapter("nestedScrollingEnabled") +fun RecyclerView.setNestedScrolling(enabled: Boolean) { + isNestedScrollingEnabled = enabled +} + +@BindingAdapter("isSelected") +fun View.isSelected(isSelected: Boolean) { + this.isSelected = isSelected +} + +@BindingAdapter("dividerVertical", "dividerHorizontal", requireAll = false) +fun RecyclerView.setDividers(dividerVertical: Drawable?, dividerHorizontal: Drawable?) { + if (dividerHorizontal != null) { + DividerItemDecoration(context, LinearLayoutManager.HORIZONTAL).apply { + setDrawable(dividerHorizontal) + }.let { addItemDecoration(it) } + } + if (dividerVertical != null) { + DividerItemDecoration(context, LinearLayoutManager.VERTICAL).apply { + setDrawable(dividerVertical) + }.let { addItemDecoration(it) } + } +} + +@BindingAdapter("icon") +fun Button.setIconRes(res: Int) { + (this as MaterialButton).setIconResource(res) +} + +@BindingAdapter("icon") +fun Button.setIcon(drawable: Drawable) { + (this as MaterialButton).icon = drawable +} + +@BindingAdapter("strokeWidth") +fun MaterialCardView.setCardStrokeWidthBound(stroke: Float) { + strokeWidth = stroke.roundToInt() +} + +@BindingAdapter("onMenuClick") +fun Toolbar.setOnMenuClickListener(listener: Toolbar.OnMenuItemClickListener) { + setOnMenuItemClickListener(listener) +} + +@BindingAdapter("onCloseClicked") +fun Chip.setOnCloseClickedListenerBinding(listener: View.OnClickListener) { + setOnCloseIconClickListener(listener) +} + +@BindingAdapter("progressAnimated") +fun ProgressBar.setProgressAnimated(newProgress: Int) { + val animator = tag as? ValueAnimator + animator?.cancel() + + ValueAnimator.ofInt(progress, newProgress).apply { + interpolator = FastOutSlowInInterpolator() + addUpdateListener { progress = it.animatedValue as Int } + tag = this + }.start() +} + +@BindingAdapter("android:text") +fun TextView.setTextSafe(text: Int) { + if (text == 0) this.text = null else setText(text) +} + +@BindingAdapter("android:onLongClick") +fun View.setOnLongClickListenerBinding(listener: () -> Unit) { + setOnLongClickListener { + listener() + true + } +} + +@BindingAdapter("strikeThrough") +fun TextView.setStrikeThroughEnabled(useStrikeThrough: Boolean) { + paintFlags = if (useStrikeThrough) { + paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } else { + paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } +} + +@BindingAdapter("spanCount") +fun RecyclerView.setSpanCount(count: Int) { + when (val lama = layoutManager) { + is GridLayoutManager -> lama.spanCount = count + is StaggeredGridLayoutManager -> lama.spanCount = count + } +} + +@BindingAdapter("state") +fun setState(view: IndeterminateCheckBox, state: Boolean?) { + if (view.state != state) + view.state = state +} + +@InverseBindingAdapter(attribute = "state") +fun getState(view: IndeterminateCheckBox) = view.state + +@BindingAdapter("stateAttrChanged") +fun setListeners( + view: IndeterminateCheckBox, + attrChange: InverseBindingListener +) { + view.setOnStateChangedListener { _, _ -> + attrChange.onChange() + } +} + +@BindingAdapter("cardBackgroundColorAttr") +fun CardView.setCardBackgroundColorAttr(attr: Int) { + val tv = TypedValue() + context.theme.resolveAttribute(attr, tv, true) + setCardBackgroundColor(tv.data) +} + +@BindingAdapter("tint") +fun ImageView.setTint(color: Int) { + ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(color)) +} + +@BindingAdapter("tintAttr") +fun ImageView.setTintAttr(attr: Int) { + val tv = TypedValue() + context.theme.resolveAttribute(attr, tv, true) + ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(tv.data)) +} + +@BindingAdapter("textColorAttr") +fun TextView.setTextColorAttr(attr: Int) { + val tv = TypedValue() + context.theme.resolveAttribute(attr, tv, true) + setTextColor(tv.data) +} + +@BindingAdapter("android:text") +fun TextView.setText(text: TextHolder) { + this.text = text.getText(context.resources) +} + +@BindingAdapter("items", "layout") +fun Spinner.setAdapter(items: Array, layoutRes: Int) { + adapter = ArrayAdapter(context, layoutRes, items) +} + +@BindingAdapter("labelFormatter") +fun Slider.setLabelFormatter(formatter: (Float) -> Int) { + setLabelFormatter { value -> resources.getString(formatter(value)) } +} + +@InverseBindingAdapter(attribute = "android:value") +fun Slider.getValueBinding() = value + +@BindingAdapter("android:valueAttrChanged") +fun Slider.setListener(attrChange: InverseBindingListener) { + addOnSliderTouchListener(object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) = Unit + override fun onStopTrackingTouch(slider: Slider) = attrChange.onChange() + }) +} + +@InverseMethod("sliderValueToPolicy") +fun policyToSliderValue(policy: Int): Float { + return when (policy) { + SuPolicy.DENY -> 1f + SuPolicy.RESTRICT -> 2f + SuPolicy.ALLOW -> 3f + else -> 1f + } +} + +fun sliderValueToPolicy(value: Float): Int { + return when (value) { + 1f -> SuPolicy.DENY + 2f -> SuPolicy.RESTRICT + 3f -> SuPolicy.ALLOW + else -> SuPolicy.DENY + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/databinding/DiffObservableList.kt b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/DiffObservableList.kt new file mode 100644 index 0000000..9626212 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/DiffObservableList.kt @@ -0,0 +1,156 @@ +package com.topjohnwu.magisk.databinding + +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import androidx.databinding.ListChangeRegistry +import androidx.databinding.ObservableList +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.AbstractList + +// Only expose the immutable List types +interface DiffList> : List { + fun calculateDiff(newItems: List): DiffUtil.DiffResult + + @MainThread + fun update(newItems: List, diffResult: DiffUtil.DiffResult) + + @WorkerThread + suspend fun update(newItems: List) +} + +interface FilterList> : List { + fun filter(filter: (T) -> Boolean) + + @MainThread + fun set(newItems: List) +} + +fun > diffList(): DiffList = DiffObservableList() + +fun > filterList(scope: CoroutineScope): FilterList = + FilterableDiffObservableList(scope) + +private open class DiffObservableList> + : AbstractList(), ObservableList, DiffList, ListUpdateCallback { + + protected var list: List = emptyList() + private val listeners = ListChangeRegistry() + + override val size: Int get() = list.size + + override fun get(index: Int) = list[index] + + override fun calculateDiff(newItems: List): DiffUtil.DiffResult { + return doCalculateDiff(list, newItems) + } + + protected fun doCalculateDiff(oldItems: List, newItems: List): DiffUtil.DiffResult { + return DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = oldItems.size + + override fun getNewListSize() = newItems.size + + @Suppress("UNCHECKED_CAST") + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return (oldItem as DiffItem).itemSameAs(newItem) + } + + @Suppress("UNCHECKED_CAST") + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return (oldItem as DiffItem).contentSameAs(newItem) + } + }, true) + } + + @MainThread + override fun update(newItems: List, diffResult: DiffUtil.DiffResult) { + list = ArrayList(newItems) + diffResult.dispatchUpdatesTo(this) + } + + @WorkerThread + override suspend fun update(newItems: List) { + val diffResult = calculateDiff(newItems) + withContext(Dispatchers.Main) { + update(newItems, diffResult) + } + } + + override fun addOnListChangedCallback(listener: ObservableList.OnListChangedCallback>) { + listeners.add(listener) + } + + override fun removeOnListChangedCallback(listener: ObservableList.OnListChangedCallback>) { + listeners.remove(listener) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + listeners.notifyChanged(this, position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + listeners.notifyMoved(this, fromPosition, toPosition, 1) + } + + override fun onInserted(position: Int, count: Int) { + modCount += 1 + listeners.notifyInserted(this, position, count) + } + + override fun onRemoved(position: Int, count: Int) { + modCount += 1 + listeners.notifyRemoved(this, position, count) + } +} + +private class FilterableDiffObservableList>( + private val scope: CoroutineScope +) : DiffObservableList(), FilterList { + + private var sublist: List = emptyList() + private var job: Job? = null + private var lastFilter: ((T) -> Boolean)? = null + + // --- + + override fun filter(filter: (T) -> Boolean) { + lastFilter = filter + job?.cancel() + job = scope.launch(Dispatchers.Default) { + val oldList = sublist + val newList = list.filter(filter) + val diff = doCalculateDiff(oldList, newList) + withContext(Dispatchers.Main) { + sublist = newList + diff.dispatchUpdatesTo(this@FilterableDiffObservableList) + } + } + } + + // --- + + override fun get(index: Int): T { + return sublist[index] + } + + override val size: Int + get() = sublist.size + + @MainThread + override fun set(newItems: List) { + onRemoved(0, sublist.size) + list = newItems + sublist = emptyList() + lastFilter?.let { filter(it) } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/databinding/MergeObservableList.kt b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/MergeObservableList.kt new file mode 100644 index 0000000..4d06f1d --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/MergeObservableList.kt @@ -0,0 +1,162 @@ +package com.topjohnwu.magisk.databinding + +import androidx.databinding.ListChangeRegistry +import androidx.databinding.ObservableList +import androidx.databinding.ObservableList.OnListChangedCallback +import java.util.AbstractList + +@Suppress("UNCHECKED_CAST") +class MergeObservableList : AbstractList(), ObservableList { + + private val lists: MutableList> = mutableListOf() + private val listeners = ListChangeRegistry() + private val callback = Callback() + + override fun addOnListChangedCallback(callback: OnListChangedCallback>) { + listeners.add(callback) + } + + override fun removeOnListChangedCallback(callback: OnListChangedCallback>) { + listeners.remove(callback) + } + + override fun get(index: Int): T { + if (index < 0) + throw IndexOutOfBoundsException() + var idx = index + for (list in lists) { + val size = list.size + if (idx < size) { + return list[idx] + } + idx -= size + } + throw IndexOutOfBoundsException() + } + + override val size: Int + get() = lists.fold(0) { i, it -> i + it.size } + + + fun insertItem(obj: T): MergeObservableList { + val idx = size + lists.add(listOf(obj)) + ++modCount + listeners.notifyInserted(this, idx, 1) + return this + } + + fun insertList(list: List): MergeObservableList { + val idx = size + lists.add(list) + ++modCount + (list as? ObservableList)?.addOnListChangedCallback(callback) + if (list.isNotEmpty()) + listeners.notifyInserted(this, idx, list.size) + return this + } + + fun removeItem(obj: T): Boolean { + var idx = 0 + for ((i, list) in lists.withIndex()) { + if (list !is ObservableList<*>) { + if (obj == list[0]) { + lists.removeAt(i) + ++modCount + listeners.notifyRemoved(this, idx, 1) + return true + } + } + idx += list.size + } + return false + } + + fun removeList(listToRemove: List): Boolean { + var idx = 0 + for ((i, list) in lists.withIndex()) { + if (listToRemove === list) { + (list as? ObservableList)?.removeOnListChangedCallback(callback) + lists.removeAt(i) + ++modCount + listeners.notifyRemoved(this, idx, list.size) + return true + } + idx += list.size + } + return false + } + + override fun clear() { + val sz = size + for (list in lists) { + if (list is ObservableList) { + list.removeOnListChangedCallback(callback) + } + } + ++modCount + lists.clear() + if (sz > 0) + listeners.notifyRemoved(this, 0, sz) + } + + private fun subIndexToIndex(subList: List<*>, index: Int): Int { + if (index < 0) + throw IndexOutOfBoundsException() + var idx = 0 + for (list in lists) { + if (subList === list) { + return idx + index + } + idx += list.size + } + throw IllegalArgumentException() + } + + inner class Callback : OnListChangedCallback>() { + override fun onChanged(sender: ObservableList) { + ++modCount + listeners.notifyChanged(this@MergeObservableList) + } + + override fun onItemRangeChanged( + sender: ObservableList, + positionStart: Int, + itemCount: Int + ) { + listeners.notifyChanged(this@MergeObservableList, + subIndexToIndex(sender, positionStart), itemCount) + } + + override fun onItemRangeInserted( + sender: ObservableList, + positionStart: Int, + itemCount: Int + ) { + ++modCount + listeners.notifyInserted(this@MergeObservableList, + subIndexToIndex(sender, positionStart), itemCount) + } + + override fun onItemRangeMoved( + sender: ObservableList, + fromPosition: Int, + toPosition: Int, + itemCount: Int + ) { + val idx = subIndexToIndex(sender, 0) + listeners.notifyMoved(this@MergeObservableList, + idx + fromPosition, idx + toPosition, itemCount) + } + + override fun onItemRangeRemoved( + sender: ObservableList, + positionStart: Int, + itemCount: Int + ) { + ++modCount + listeners.notifyRemoved(this@MergeObservableList, + subIndexToIndex(sender, positionStart), itemCount) + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/databinding/ObservableHost.kt b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/ObservableHost.kt new file mode 100644 index 0000000..5e38dbe --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/ObservableHost.kt @@ -0,0 +1,97 @@ +package com.topjohnwu.magisk.databinding + +import androidx.databinding.Observable +import androidx.databinding.PropertyChangeRegistry + +/** + * Modified from https://github.com/skoumalcz/teanity/blob/1.2/core/src/main/java/com/skoumal/teanity/observable/Notifyable.kt + * + * Interface that allows user to be observed via DataBinding or manually by assigning listeners. + * + * @see [androidx.databinding.Observable] + * */ +interface ObservableHost : Observable { + + var callbacks: PropertyChangeRegistry? + + /** + * Notifies all observers that something has changed. By default implementation this method is + * synchronous, hence observers will never be notified in undefined order. Observers might + * choose to refresh the view completely, which is beyond the scope of this function. + * */ + fun notifyChange() { + synchronized(this) { + callbacks ?: return + }.notifyCallbacks(this, 0, null) + } + + /** + * Notifies all observers about field with [fieldId] has been changed. This will happen + * synchronously before or after [notifyChange] has been called. It will never be called during + * the execution of aforementioned method. + * */ + fun notifyPropertyChanged(fieldId: Int) { + synchronized(this) { + callbacks ?: return + }.notifyCallbacks(this, fieldId, null) + } + + override fun addOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) { + synchronized(this) { + callbacks ?: PropertyChangeRegistry().also { callbacks = it } + }.add(callback) + } + + override fun removeOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) { + synchronized(this) { + callbacks ?: return + }.remove(callback) + } +} + +fun ObservableHost.addOnPropertyChangedCallback( + fieldId: Int, + removeAfterChanged: Boolean = false, + callback: () -> Unit +) { + addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable?, propertyId: Int) { + if (fieldId == propertyId) { + callback() + if (removeAfterChanged) + removeOnPropertyChangedCallback(this) + } + } + }) +} + +/** + * Injects boilerplate implementation for {@literal @}[androidx.databinding.Bindable] field setters. + * + * # Examples: + * ```kotlin + * @get:Bindable + * var myField = defaultValue + * set(value) = set(value, field, { field = it }, BR.myField) { + * doSomething(it) + * } + * ``` + * */ + +inline fun ObservableHost.set( + new: T, old: T, setter: (T) -> Unit, fieldId: Int, afterChanged: (T) -> Unit = {}) { + if (old != new) { + setter(new) + notifyPropertyChanged(fieldId) + afterChanged(new) + } +} + +inline fun ObservableHost.set( + new: T, old: T, setter: (T) -> Unit, vararg fieldIds: Int, afterChanged: (T) -> Unit = {}) { + if (old != new) { + setter(new) + fieldIds.forEach { notifyPropertyChanged(it) } + afterChanged(new) + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/databinding/RecyclerViewItems.kt b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/RecyclerViewItems.kt new file mode 100644 index 0000000..7b26f83 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/RecyclerViewItems.kt @@ -0,0 +1,35 @@ +package com.topjohnwu.magisk.databinding + +import androidx.databinding.PropertyChangeRegistry +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +abstract class RvItem { + abstract val layoutRes: Int +} + +abstract class ObservableRvItem : RvItem(), ObservableHost { + override var callbacks: PropertyChangeRegistry? = null +} + +interface ItemWrapper { + val item: E +} + +interface ViewAwareItem { + fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) +} + +interface DiffItem { + + fun itemSameAs(other: T): Boolean { + if (this === other) return true + return when (this) { + is ItemWrapper<*> -> item == (other as ItemWrapper<*>).item + is Comparable<*> -> compareValues(this, other as Comparable<*>) == 0 + else -> this == other + } + } + + fun contentSameAs(other: T) = true +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/databinding/RvItemAdapter.kt b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/RvItemAdapter.kt new file mode 100644 index 0000000..97ad668 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/databinding/RvItemAdapter.kt @@ -0,0 +1,121 @@ +package com.topjohnwu.magisk.databinding + +import android.annotation.SuppressLint +import android.util.SparseArray +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.BindingAdapter +import androidx.databinding.DataBindingUtil +import androidx.databinding.ObservableList +import androidx.databinding.ObservableList.OnListChangedCallback +import androidx.databinding.ViewDataBinding +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import com.topjohnwu.magisk.BR + +class RvItemAdapter( + val items: List, + val extraBindings: SparseArray<*>? +) : RecyclerView.Adapter() { + + private var lifecycleOwner: LifecycleOwner? = null + private var recyclerView: RecyclerView? = null + private val observer by lazy(LazyThreadSafetyMode.NONE) { ListObserver() } + + override fun onAttachedToRecyclerView(rv: RecyclerView) { + lifecycleOwner = rv.findViewTreeLifecycleOwner() + recyclerView = rv + if (items is ObservableList) + items.addOnListChangedCallback(observer) + } + + override fun onDetachedFromRecyclerView(rv: RecyclerView) { + lifecycleOwner = null + recyclerView = null + if (items is ObservableList) + items.removeOnListChangedCallback(observer) + } + + override fun onCreateViewHolder(parent: ViewGroup, layoutRes: Int): ViewHolder { + val inflator = LayoutInflater.from(parent.context) + return ViewHolder(DataBindingUtil.inflate(inflator, layoutRes, parent, false)) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + holder.binding.setVariable(BR.item, item) + extraBindings?.let { + for (i in 0 until it.size()) { + holder.binding.setVariable(it.keyAt(i), it.valueAt(i)) + } + } + holder.binding.lifecycleOwner = lifecycleOwner + holder.binding.executePendingBindings() + recyclerView?.let { + if (item is ViewAwareItem) + item.onBind(holder.binding, it) + } + } + + override fun getItemCount() = items.size + + override fun getItemViewType(position: Int) = items[position].layoutRes + + class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) + + inner class ListObserver : OnListChangedCallback>() { + + @SuppressLint("NotifyDataSetChanged") + override fun onChanged(sender: ObservableList) { + notifyDataSetChanged() + } + + override fun onItemRangeChanged( + sender: ObservableList, + positionStart: Int, + itemCount: Int + ) { + notifyItemRangeChanged(positionStart, itemCount) + } + + override fun onItemRangeInserted( + sender: ObservableList?, + positionStart: Int, + itemCount: Int + ) { + notifyItemRangeInserted(positionStart, itemCount) + } + + override fun onItemRangeMoved( + sender: ObservableList?, + fromPosition: Int, + toPosition: Int, + itemCount: Int + ) { + for (i in 0 until itemCount) { + notifyItemMoved(fromPosition + i, toPosition + i) + } + } + + override fun onItemRangeRemoved( + sender: ObservableList?, + positionStart: Int, + itemCount: Int + ) { + notifyItemRangeRemoved(positionStart, itemCount) + } + } +} + +inline fun bindExtra(body: (SparseArray) -> Unit) = SparseArray().also(body) + +@BindingAdapter("items", "extraBindings", requireAll = false) +fun RecyclerView.setAdapter(items: List?, extraBindings: SparseArray<*>?) { + if (items != null) { + val rva = (adapter as? RvItemAdapter<*>) + if (rva == null || rva.items !== items || rva.extraBindings !== extraBindings) { + adapter = RvItemAdapter(items, extraBindings) + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/DarkThemeDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/DarkThemeDialog.kt new file mode 100644 index 0000000..68e9514 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/DarkThemeDialog.kt @@ -0,0 +1,41 @@ +package com.topjohnwu.magisk.dialog + +import android.app.Activity +import androidx.appcompat.app.AppCompatDelegate +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.UIActivity +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.magisk.core.R as CoreR + +class DarkThemeDialog : DialogBuilder { + + override fun build(dialog: MagiskDialog) { + val activity = dialog.ownerActivity!! + dialog.apply { + setTitle(CoreR.string.settings_dark_mode_title) + setMessage(CoreR.string.settings_dark_mode_message) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = CoreR.string.settings_dark_mode_light + icon = R.drawable.ic_day + onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_NO, activity) } + } + setButton(MagiskDialog.ButtonType.NEUTRAL) { + text = CoreR.string.settings_dark_mode_system + icon = R.drawable.ic_day_night + onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, activity) } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = CoreR.string.settings_dark_mode_dark + icon = R.drawable.ic_night + onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_YES, activity) } + } + } + } + + private fun selectTheme(mode: Int, activity: Activity) { + Config.darkTheme = mode + (activity as UIActivity<*>).delegate.localNightMode = mode + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/EnvFixDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/EnvFixDialog.kt new file mode 100644 index 0000000..99ce2e0 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/EnvFixDialog.kt @@ -0,0 +1,65 @@ +package com.topjohnwu.magisk.dialog + +import android.widget.Toast +import androidx.core.os.postDelayed +import androidx.lifecycle.lifecycleScope +import com.topjohnwu.magisk.core.BuildConfig +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.ktx.reboot +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.tasks.MagiskInstaller +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.ui.home.HomeViewModel +import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.superuser.internal.UiThreadHandler +import kotlinx.coroutines.launch + +class EnvFixDialog(private val vm: HomeViewModel, private val code: Int) : DialogBuilder { + + override fun build(dialog: MagiskDialog) { + dialog.apply { + setTitle(R.string.env_fix_title) + setMessage(R.string.env_fix_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + doNotDismiss = true + onClick { + dialog.apply { + setTitle(R.string.setup_title) + setMessage(R.string.setup_msg) + resetButtons() + setCancelable(false) + } + dialog.activity.lifecycleScope.launch { + MagiskInstaller.FixEnv().exec { success -> + dialog.dismiss() + context.toast( + if (success) R.string.reboot_delay_toast else R.string.setup_fail, + Toast.LENGTH_LONG + ) + if (success) + UiThreadHandler.handler.postDelayed(5000) { reboot() } + } + } + } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + } + + if (code == 2 || // No rules block, module policy not loaded + Info.env.versionCode != BuildConfig.APP_VERSION_CODE || + Info.env.versionString != BuildConfig.APP_VERSION_NAME) { + dialog.setMessage(R.string.env_full_fix_msg) + dialog.setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { + vm.onMagiskPressed() + dialog.dismiss() + } + } + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/LocalModuleInstallDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/LocalModuleInstallDialog.kt new file mode 100644 index 0000000..44bbf33 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/LocalModuleInstallDialog.kt @@ -0,0 +1,33 @@ +package com.topjohnwu.magisk.dialog + +import android.net.Uri +import com.topjohnwu.magisk.MainDirections +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.ui.module.ModuleViewModel +import com.topjohnwu.magisk.view.MagiskDialog + +class LocalModuleInstallDialog( + private val viewModel: ModuleViewModel, + private val uri: Uri, + private val displayName: String +) : DialogBuilder { + override fun build(dialog: MagiskDialog) { + dialog.apply { + setTitle(R.string.confirm_install_title) + setMessage(context.getString(R.string.confirm_install, displayName)) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { + viewModel.apply { + MainDirections.actionFlashFragment(Const.Value.FLASH_ZIP, uri).navigate() + } + } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt new file mode 100644 index 0000000..6018aa6 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt @@ -0,0 +1,34 @@ +package com.topjohnwu.magisk.dialog + +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.download.DownloadEngine +import com.topjohnwu.magisk.core.download.Subject +import com.topjohnwu.magisk.view.MagiskDialog +import java.io.File + +class ManagerInstallDialog : MarkDownDialog() { + + override suspend fun getMarkdownText(): String { + val text = Info.update.note + // Cache the changelog + File(AppContext.cacheDir, "${Info.update.versionCode}.md").writeText(text) + return text + } + + override fun build(dialog: MagiskDialog) { + super.build(dialog) + dialog.apply { + setCancelable(true) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = R.string.install + onClick { DownloadEngine.startWithActivity(activity, Subject.App()) } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + } + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/MarkDownDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/MarkDownDialog.kt new file mode 100644 index 0000000..e3b0c81 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/MarkDownDialog.kt @@ -0,0 +1,39 @@ +package com.topjohnwu.magisk.dialog + +import android.view.LayoutInflater +import android.widget.TextView +import androidx.annotation.CallSuper +import androidx.lifecycle.lifecycleScope +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.di.ServiceLocator +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.view.MagiskDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.IOException +import com.topjohnwu.magisk.core.R as CoreR + +abstract class MarkDownDialog : DialogBuilder { + + abstract suspend fun getMarkdownText(): String + + @CallSuper + override fun build(dialog: MagiskDialog) { + with(dialog) { + val view = LayoutInflater.from(context).inflate(R.layout.markdown_window_md2, null) + setView(view) + val tv = view.findViewById(R.id.md_txt) + activity.lifecycleScope.launch { + try { + val text = withContext(Dispatchers.IO) { getMarkdownText() } + ServiceLocator.markwon.setMarkdown(tv, text) + } catch (e: IOException) { + Timber.e(e) + tv.setText(CoreR.string.download_file_error) + } + } + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/OnlineModuleInstallDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/OnlineModuleInstallDialog.kt new file mode 100644 index 0000000..6906643 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/OnlineModuleInstallDialog.kt @@ -0,0 +1,59 @@ +package com.topjohnwu.magisk.dialog + +import android.content.Context +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.di.ServiceLocator +import com.topjohnwu.magisk.core.download.DownloadEngine +import com.topjohnwu.magisk.core.download.Subject +import com.topjohnwu.magisk.core.model.module.OnlineModule +import com.topjohnwu.magisk.ui.flash.FlashFragment +import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.magisk.view.Notifications +import kotlinx.parcelize.Parcelize + +class OnlineModuleInstallDialog(private val item: OnlineModule) : MarkDownDialog() { + + private val svc get() = ServiceLocator.networkService + + override suspend fun getMarkdownText(): String { + val str = svc.fetchString(item.changelog) + return if (str.length > 1000) str.substring(0, 1000) else str + } + + @Parcelize + class Module( + override val module: OnlineModule, + override val autoLaunch: Boolean, + override val notifyId: Int = Notifications.nextId() + ) : Subject.Module() { + override fun pendingIntent(context: Context) = FlashFragment.installIntent(context, file) + } + + override fun build(dialog: MagiskDialog) { + super.build(dialog) + dialog.apply { + + fun download(install: Boolean) { + DownloadEngine.startWithActivity(activity, Module(item, install)) + } + + val title = context.getString(R.string.repo_install_title, + item.name, item.version, item.versionCode) + + setTitle(title) + setCancelable(true) + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = R.string.download + onClick { download(false) } + } + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = R.string.install + onClick { download(true) } + } + setButton(MagiskDialog.ButtonType.NEUTRAL) { + text = android.R.string.cancel + } + } + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/SecondSlotWarningDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/SecondSlotWarningDialog.kt new file mode 100644 index 0000000..5529c4d --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/SecondSlotWarningDialog.kt @@ -0,0 +1,19 @@ +package com.topjohnwu.magisk.dialog + +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.view.MagiskDialog + +class SecondSlotWarningDialog : DialogBuilder { + + override fun build(dialog: MagiskDialog) { + dialog.apply { + setTitle(android.R.string.dialog_alert_title) + setMessage(R.string.install_inactive_slot_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + } + setCancelable(true) + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/SuperuserRevokeDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/SuperuserRevokeDialog.kt new file mode 100644 index 0000000..ee30aef --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/SuperuserRevokeDialog.kt @@ -0,0 +1,25 @@ +package com.topjohnwu.magisk.dialog + +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.view.MagiskDialog + +class SuperuserRevokeDialog( + private val appName: String, + private val onSuccess: () -> Unit +) : DialogBuilder { + + override fun build(dialog: MagiskDialog) { + dialog.apply { + setTitle(R.string.su_revoke_title) + setMessage(R.string.su_revoke_msg, appName) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { onSuccess() } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/dialog/UninstallDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/UninstallDialog.kt new file mode 100644 index 0000000..b292ad5 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/dialog/UninstallDialog.kt @@ -0,0 +1,57 @@ +package com.topjohnwu.magisk.dialog + +import android.app.ProgressDialog +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import com.topjohnwu.magisk.arch.NavigationActivity +import com.topjohnwu.magisk.arch.UIActivity +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.tasks.MagiskInstaller +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.ui.flash.FlashFragment +import com.topjohnwu.magisk.view.MagiskDialog +import kotlinx.coroutines.launch + +class UninstallDialog : DialogBuilder { + + override fun build(dialog: MagiskDialog) { + dialog.apply { + setTitle(R.string.uninstall_magisk_title) + setMessage(R.string.uninstall_magisk_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = R.string.restore_img + onClick { restore(dialog.activity) } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = R.string.complete_uninstall + onClick { completeUninstall(dialog) } + } + } + } + + @Suppress("DEPRECATION") + private fun restore(activity: UIActivity<*>) { + val dialog = ProgressDialog(activity).apply { + setMessage(activity.getString(R.string.restore_img_msg)) + show() + } + + activity.lifecycleScope.launch { + MagiskInstaller.Restore().exec { success -> + dialog.dismiss() + if (success) { + activity.toast(R.string.restore_done, Toast.LENGTH_SHORT) + } else { + activity.toast(R.string.restore_fail, Toast.LENGTH_LONG) + } + } + } + } + + private fun completeUninstall(dialog: MagiskDialog) { + (dialog.ownerActivity as NavigationActivity<*>) + .navigation.navigate(FlashFragment.uninstall()) + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/events/ViewEvents.kt b/app/apk/src/main/java/com/topjohnwu/magisk/events/ViewEvents.kt new file mode 100644 index 0000000..c73fd31 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/events/ViewEvents.kt @@ -0,0 +1,124 @@ +package com.topjohnwu.magisk.events + +import android.content.Context +import android.view.View +import androidx.annotation.StringRes +import androidx.navigation.NavDirections +import com.google.android.material.snackbar.Snackbar +import com.topjohnwu.magisk.arch.ActivityExecutor +import com.topjohnwu.magisk.arch.ContextExecutor +import com.topjohnwu.magisk.arch.NavigationActivity +import com.topjohnwu.magisk.arch.UIActivity +import com.topjohnwu.magisk.arch.ViewEvent +import com.topjohnwu.magisk.core.base.ContentResultCallback +import com.topjohnwu.magisk.core.base.relaunch +import com.topjohnwu.magisk.utils.TextHolder +import com.topjohnwu.magisk.utils.asText +import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.magisk.view.Shortcuts + +class PermissionEvent( + private val permission: String, + private val callback: (Boolean) -> Unit +) : ViewEvent(), ActivityExecutor { + + override fun invoke(activity: UIActivity<*>) = + activity.withPermission(permission, callback) +} + +class BackPressEvent : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + activity.onBackPressed() + } +} + +class DieEvent : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + activity.finish() + } +} + +class ShowUIEvent(private val delegate: View.AccessibilityDelegate?) + : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + activity.setContentView() + activity.setAccessibilityDelegate(delegate) + } +} + +class RecreateEvent : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + activity.relaunch() + } +} + +class AuthEvent( + private val callback: () -> Unit +) : ViewEvent(), ActivityExecutor { + + override fun invoke(activity: UIActivity<*>) { + activity.withAuthentication { if (it) callback() } + } +} + +class GetContentEvent( + private val type: String, + private val callback: ContentResultCallback +) : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + activity.getContent(type, callback) + } +} + +class NavigationEvent( + private val directions: NavDirections, + private val pop: Boolean +) : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + (activity as? NavigationActivity<*>)?.apply { + if (pop) navigation.popBackStack() + directions.navigate() + } + } +} + +class AddHomeIconEvent : ViewEvent(), ContextExecutor { + override fun invoke(context: Context) { + Shortcuts.addHomeIcon(context) + } +} + +class SnackbarEvent( + private val msg: TextHolder, + private val length: Int = Snackbar.LENGTH_SHORT, + private val builder: Snackbar.() -> Unit = {} +) : ViewEvent(), ActivityExecutor { + + constructor( + @StringRes res: Int, + length: Int = Snackbar.LENGTH_SHORT, + builder: Snackbar.() -> Unit = {} + ) : this(res.asText(), length, builder) + + constructor( + msg: String, + length: Int = Snackbar.LENGTH_SHORT, + builder: Snackbar.() -> Unit = {} + ) : this(msg.asText(), length, builder) + + override fun invoke(activity: UIActivity<*>) { + activity.showSnackbar(msg.getText(activity.resources), length, builder) + } +} + +class DialogEvent( + private val builder: DialogBuilder +) : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + MagiskDialog(activity).apply(builder::build).show() + } +} + +interface DialogBuilder { + fun build(dialog: MagiskDialog) +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt new file mode 100644 index 0000000..a277224 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt @@ -0,0 +1,278 @@ +package com.topjohnwu.magisk.ui + +import android.Manifest +import android.Manifest.permission.REQUEST_INSTALL_PACKAGES +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.WindowManager +import android.widget.Toast +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.view.forEach +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavDirections +import com.topjohnwu.magisk.MainDirections +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.arch.NavigationActivity +import com.topjohnwu.magisk.arch.startAnimations +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.base.SplashController +import com.topjohnwu.magisk.core.base.SplashScreenHost +import com.topjohnwu.magisk.core.isRunningAsStub +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.model.module.LocalModule +import com.topjohnwu.magisk.core.tasks.AppMigration +import com.topjohnwu.magisk.databinding.ActivityMainMd2Binding +import com.topjohnwu.magisk.ui.home.HomeFragmentDirections +import com.topjohnwu.magisk.ui.theme.Theme +import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.magisk.view.Shortcuts +import kotlinx.coroutines.launch +import java.io.File +import com.topjohnwu.magisk.core.R as CoreR + +class MainViewModel : BaseViewModel() + +class MainActivity : NavigationActivity(), SplashScreenHost { + + override val layoutRes = R.layout.activity_main_md2 + override val viewModel by viewModel() + override val navHostId: Int = R.id.main_nav_host + override val splashController = SplashController(this) + override val snackbarView: View + get() { + val fragmentOverride = currentFragment?.snackbarView + return fragmentOverride ?: super.snackbarView + } + override val snackbarAnchorView: View? + get() { + val fragmentAnchor = currentFragment?.snackbarAnchorView + return when { + fragmentAnchor?.isVisible == true -> fragmentAnchor + binding.mainNavigation.isVisible -> return binding.mainNavigation + else -> null + } + } + + private var isRootFragment = true + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Theme.selected.themeRes) + splashController.preOnCreate() + super.onCreate(savedInstanceState) + splashController.onCreate(savedInstanceState) + } + + override fun onResume() { + super.onResume() + splashController.onResume() + } + + @SuppressLint("InlinedApi") + override fun onCreateUi(savedInstanceState: Bundle?) { + setContentView() + showUnsupportedMessage() + askForHomeShortcut() + + // Ask permission to post notifications for background update check + if (Config.checkUpdate) { + withPermission(Manifest.permission.POST_NOTIFICATIONS) { + Config.checkUpdate = it + } + } + + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + navigation.addOnDestinationChangedListener { _, destination, _ -> + isRootFragment = when (destination.id) { + R.id.homeFragment, + R.id.modulesFragment, + R.id.superuserFragment, + R.id.logFragment -> true + else -> false + } + + setDisplayHomeAsUpEnabled(!isRootFragment) + requestNavigationHidden(!isRootFragment) + + binding.mainNavigation.menu.forEach { + if (it.itemId == destination.id) { + it.isChecked = true + } + } + } + + setSupportActionBar(binding.mainToolbar) + + binding.mainNavigation.setOnItemSelectedListener { + getScreen(it.itemId)?.navigate() + true + } + binding.mainNavigation.setOnItemReselectedListener { + // https://issuetracker.google.com/issues/124538620 + } + binding.mainNavigation.menu.apply { + findItem(R.id.superuserFragment)?.isEnabled = Info.showSuperUser + findItem(R.id.modulesFragment)?.isEnabled = Info.env.isActive && LocalModule.loaded() + } + + val section = + if (intent.action == Intent.ACTION_APPLICATION_PREFERENCES) + Const.Nav.SETTINGS + else + intent.getStringExtra(Const.Key.OPEN_SECTION) + + getScreen(section)?.navigate() + + if (!isRootFragment) { + requestNavigationHidden(requiresAnimation = savedInstanceState == null) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> onBackPressed() + else -> return super.onOptionsItemSelected(item) + } + return true + } + + fun setDisplayHomeAsUpEnabled(isEnabled: Boolean) { + binding.mainToolbar.startAnimations() + when { + isEnabled -> binding.mainToolbar.setNavigationIcon(R.drawable.ic_back_md2) + else -> binding.mainToolbar.navigationIcon = null + } + } + + internal fun requestNavigationHidden(hide: Boolean = true, requiresAnimation: Boolean = true) { + val bottomView = binding.mainNavigation + if (requiresAnimation) { + bottomView.isVisible = true + bottomView.isHidden = hide + } else { + bottomView.isGone = hide + } + } + + fun invalidateToolbar() { + //binding.mainToolbar.startAnimations() + binding.mainToolbar.invalidate() + } + + private fun getScreen(name: String?): NavDirections? { + return when (name) { + Const.Nav.SUPERUSER -> MainDirections.actionSuperuserFragment() + Const.Nav.MODULES -> MainDirections.actionModuleFragment() + Const.Nav.SETTINGS -> HomeFragmentDirections.actionHomeFragmentToSettingsFragment() + else -> null + } + } + + private fun getScreen(id: Int): NavDirections? { + return when (id) { + R.id.homeFragment -> MainDirections.actionHomeFragment() + R.id.modulesFragment -> MainDirections.actionModuleFragment() + R.id.superuserFragment -> MainDirections.actionSuperuserFragment() + R.id.logFragment -> MainDirections.actionLogFragment() + else -> null + } + } + + @SuppressLint("InlinedApi") + override fun showInvalidStateMessage(): Unit = runOnUiThread { + MagiskDialog(this).apply { + setTitle(CoreR.string.unsupport_nonroot_stub_title) + setMessage(CoreR.string.unsupport_nonroot_stub_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = CoreR.string.install + onClick { + withPermission(REQUEST_INSTALL_PACKAGES) { + if (!it) { + toast(CoreR.string.install_unknown_denied, Toast.LENGTH_SHORT) + showInvalidStateMessage() + } else { + lifecycleScope.launch { + AppMigration.restore(this@MainActivity) + } + } + } + } + } + setCancelable(false) + show() + } + } + + private fun showUnsupportedMessage() { + if (Info.env.isUnsupported) { + MagiskDialog(this).apply { + setTitle(CoreR.string.unsupport_magisk_title) + setMessage(CoreR.string.unsupport_magisk_msg, Const.Version.MIN_VERSION) + setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok } + setCancelable(false) + }.show() + } + + if (!Info.isEmulator && Info.env.isActive && System.getenv("PATH") + ?.split(':') + ?.filterNot { File("$it/magisk").exists() } + ?.any { File("$it/su").exists() } == true) { + MagiskDialog(this).apply { + setTitle(CoreR.string.unsupport_general_title) + setMessage(CoreR.string.unsupport_other_su_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok } + setCancelable(false) + }.show() + } + + if (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) { + MagiskDialog(this).apply { + setTitle(CoreR.string.unsupport_general_title) + setMessage(CoreR.string.unsupport_system_app_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok } + setCancelable(false) + }.show() + } + + if (applicationInfo.flags and ApplicationInfo.FLAG_EXTERNAL_STORAGE != 0) { + MagiskDialog(this).apply { + setTitle(CoreR.string.unsupport_general_title) + setMessage(CoreR.string.unsupport_external_storage_msg) + setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok } + setCancelable(false) + }.show() + } + } + + private fun askForHomeShortcut() { + if (isRunningAsStub && !Config.askedHome && + ShortcutManagerCompat.isRequestPinShortcutSupported(this)) { + // Ask and show dialog + Config.askedHome = true + MagiskDialog(this).apply { + setTitle(CoreR.string.add_shortcut_title) + setMessage(CoreR.string.add_shortcut_msg) + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { + Shortcuts.addHomeIcon(this@MainActivity) + } + } + setCancelable(true) + }.show() + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/AppProcessInfo.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/AppProcessInfo.kt new file mode 100644 index 0000000..9863279 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/AppProcessInfo.kt @@ -0,0 +1,118 @@ +package com.topjohnwu.magisk.ui.deny + +import android.annotation.SuppressLint +import android.content.pm.ApplicationInfo +import android.content.pm.ComponentInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.GET_ACTIVITIES +import android.content.pm.PackageManager.GET_PROVIDERS +import android.content.pm.PackageManager.GET_RECEIVERS +import android.content.pm.PackageManager.GET_SERVICES +import android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS +import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES +import android.content.pm.ServiceInfo +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import androidx.core.os.ProcessCompat +import com.topjohnwu.magisk.core.ktx.getLabel +import java.util.Locale +import java.util.TreeSet + +class CmdlineListItem(line: String) { + val packageName: String + val process: String + + init { + val split = line.split(Regex("\\|"), 2) + packageName = split[0] + process = split.getOrElse(1) { packageName } + } +} + +const val ISOLATED_MAGIC = "isolated" + +@SuppressLint("InlinedApi") +class AppProcessInfo( + private val info: ApplicationInfo, + pm: PackageManager, + denyList: List +) : Comparable { + + private val denyList = denyList.filter { + it.packageName == info.packageName || it.packageName == ISOLATED_MAGIC + } + + val label = info.getLabel(pm) + val iconImage: Drawable = runCatching { info.loadIcon(pm) }.getOrDefault(pm.defaultActivityIcon) + val packageName: String get() = info.packageName + val processes = fetchProcesses(pm) + + override fun compareTo(other: AppProcessInfo) = comparator.compare(this, other) + + fun isSystemApp() = info.flags and ApplicationInfo.FLAG_SYSTEM != 0 + + fun isApp() = ProcessCompat.isApplicationUid(info.uid) + + private fun createProcess(name: String, pkg: String = info.packageName) = + ProcessInfo(name, pkg, denyList.any { it.process == name && it.packageName == pkg }) + + private fun ComponentInfo.getProcName(): String = processName + ?: applicationInfo.processName + ?: applicationInfo.packageName + + private val ServiceInfo.isIsolated get() = (flags and ServiceInfo.FLAG_ISOLATED_PROCESS) != 0 + private val ServiceInfo.useAppZygote get() = (flags and ServiceInfo.FLAG_USE_APP_ZYGOTE) != 0 + + private fun Array?.toProcessList() = + orEmpty().map { createProcess(it.getProcName()) } + + private fun Array?.toProcessList() = orEmpty().map { + if (it.isIsolated) { + if (it.useAppZygote) { + val proc = info.processName ?: info.packageName + createProcess("${proc}_zygote") + } else { + val proc = if (SDK_INT >= Build.VERSION_CODES.Q) + "${it.getProcName()}:${it.name}" else it.getProcName() + createProcess(proc, ISOLATED_MAGIC) + } + } else { + createProcess(it.getProcName()) + } + } + + private fun fetchProcesses(pm: PackageManager): Collection { + val flag = MATCH_DISABLED_COMPONENTS or MATCH_UNINSTALLED_PACKAGES or + GET_ACTIVITIES or GET_SERVICES or GET_RECEIVERS or GET_PROVIDERS + val packageInfo = try { + pm.getPackageInfo(info.packageName, flag) + } catch (e: Exception) { + // Exceed binder data transfer limit, parse the package locally + pm.getPackageArchiveInfo(info.sourceDir, flag) ?: return emptyList() + } + + val processSet = TreeSet(compareBy({ it.name }, { it.isIsolated })) + processSet += packageInfo.activities.toProcessList() + processSet += packageInfo.services.toProcessList() + processSet += packageInfo.receivers.toProcessList() + processSet += packageInfo.providers.toProcessList() + return processSet + } + + companion object { + private val comparator = compareBy( + { it.label.lowercase(Locale.ROOT) }, + { it.info.packageName } + ) + } +} + +data class ProcessInfo( + val name: String, + val packageName: String, + var isEnabled: Boolean +) { + val isIsolated = packageName == ISOLATED_MAGIC + val isAppZygote = name.endsWith("_zygote") +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListFragment.kt new file mode 100644 index 0000000..5f8bbca --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListFragment.kt @@ -0,0 +1,99 @@ +package com.topjohnwu.magisk.ui.deny + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider +import androidx.recyclerview.widget.RecyclerView +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.ktx.hideKeyboard +import com.topjohnwu.magisk.databinding.FragmentDenyMd2Binding +import rikka.recyclerview.addEdgeSpacing +import rikka.recyclerview.addItemSpacing +import rikka.recyclerview.fixEdgeEffect +import com.topjohnwu.magisk.core.R as CoreR + +class DenyListFragment : BaseFragment(), MenuProvider { + + override val layoutRes = R.layout.fragment_deny_md2 + override val viewModel by viewModel() + + private lateinit var searchView: SearchView + + override fun onStart() { + super.onStart() + activity?.setTitle(CoreR.string.denylist) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.appList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + if (newState != RecyclerView.SCROLL_STATE_IDLE) activity?.hideKeyboard() + } + }) + + binding.appList.apply { + addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1) + addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1) + fixEdgeEffect() + } + } + + override fun onPreBind(binding: FragmentDenyMd2Binding) = Unit + + override fun onBackPressed(): Boolean { + if (searchView.isIconfiedByDefault && !searchView.isIconified) { + searchView.isIconified = true + return true + } + return super.onBackPressed() + } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_deny_md2, menu) + searchView = menu.findItem(R.id.action_search).actionView as SearchView + searchView.queryHint = searchView.context.getString(CoreR.string.hide_filter_hint) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + viewModel.query = query ?: "" + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.query = newText ?: "" + return true + } + }) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_show_system -> { + val check = !item.isChecked + viewModel.isShowSystem = check + item.isChecked = check + return true + } + R.id.action_show_OS -> { + val check = !item.isChecked + viewModel.isShowOS = check + item.isChecked = check + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onPrepareMenu(menu: Menu) { + val showSystem = menu.findItem(R.id.action_show_system) + val showOS = menu.findItem(R.id.action_show_OS) + showOS.isEnabled = showSystem.isChecked + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListRvItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListRvItem.kt new file mode 100644 index 0000000..b301991 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListRvItem.kt @@ -0,0 +1,130 @@ +package com.topjohnwu.magisk.ui.deny + +import android.view.View +import android.view.ViewGroup +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.startAnimations +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ObservableRvItem +import com.topjohnwu.magisk.databinding.addOnPropertyChangedCallback +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.superuser.Shell +import kotlin.math.roundToInt + +class DenyListRvItem( + val info: AppProcessInfo +) : ObservableRvItem(), DiffItem, Comparable { + + override val layoutRes get() = R.layout.item_hide_md2 + + val processes = info.processes.map { ProcessRvItem(it) } + + @get:Bindable + var isExpanded = false + set(value) = set(value, field, { field = it }, BR.expanded) + + var itemsChecked = 0 + set(value) = set(value, field, { field = it }, BR.checkedPercent) + + val isChecked get() = itemsChecked != 0 + + @get:Bindable + val checkedPercent get() = (itemsChecked.toFloat() / processes.size * 100).roundToInt() + + private var _state: Boolean? = false + set(value) = set(value, field, { field = it }, BR.state) + + @get:Bindable + var state: Boolean? + get() = _state + set(value) = set(value, _state, { _state = it }, BR.state) { + if (value == true) { + processes + .filterNot { it.isEnabled } + .filter { isExpanded || it.defaultSelection } + .forEach { it.toggle() } + } else { + Shell.cmd("magisk --denylist rm ${info.packageName}").submit() + processes.filter { it.isEnabled }.forEach { + if (it.process.isIsolated) { + it.toggle() + } else { + it.isEnabled = !it.isEnabled + notifyPropertyChanged(BR.enabled) + } + } + } + } + + init { + processes.forEach { it.addOnPropertyChangedCallback(BR.enabled) { recalculateChecked() } } + addOnPropertyChangedCallback(BR.expanded) { recalculateChecked() } + recalculateChecked() + } + + fun toggleExpand(v: View) { + (v.parent as? ViewGroup)?.startAnimations() + isExpanded = !isExpanded + } + + private fun recalculateChecked() { + itemsChecked = processes.count { it.isEnabled } + _state = if (isExpanded) { + when (itemsChecked) { + 0 -> false + processes.size -> true + else -> null + } + } else { + val defaultProcesses = processes.filter { it.defaultSelection } + when (defaultProcesses.count { it.isEnabled }) { + 0 -> false + defaultProcesses.size -> true + else -> null + } + } + } + + override fun compareTo(other: DenyListRvItem) = comparator.compare(this, other) + + companion object { + private val comparator = compareBy( + { it.itemsChecked == 0 }, + { it.info } + ) + } + +} + +class ProcessRvItem( + val process: ProcessInfo +) : ObservableRvItem(), DiffItem { + + override val layoutRes get() = R.layout.item_hide_process_md2 + + val displayName = if (process.isIsolated) "(isolated) ${process.name}" else process.name + + @get:Bindable + var isEnabled + get() = process.isEnabled + set(value) = set(value, process.isEnabled, { process.isEnabled = it }, BR.enabled) { + val arg = if (it) "add" else "rm" + val (name, pkg) = process + Shell.cmd("magisk --denylist $arg $pkg \'$name\'").submit() + } + + fun toggle() { + isEnabled = !isEnabled + } + + val defaultSelection get() = + process.isIsolated || process.isAppZygote || process.name == process.packageName + + override fun itemSameAs(other: ProcessRvItem) = + process.name == other.process.name && process.packageName == other.process.packageName + + override fun contentSameAs(other: ProcessRvItem) = + process.isEnabled == other.process.isEnabled +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt new file mode 100644 index 0000000..dfca985 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt @@ -0,0 +1,89 @@ +package com.topjohnwu.magisk.ui.deny + +import android.annotation.SuppressLint +import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES +import androidx.databinding.Bindable +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.arch.AsyncLoadViewModel +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.ktx.concurrentMap +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.databinding.filterList +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.withContext + +class DenyListViewModel : AsyncLoadViewModel() { + + var isShowSystem = false + set(value) { + field = value + doQuery(query) + } + + var isShowOS = false + set(value) { + field = value + doQuery(query) + } + + var query = "" + set(value) { + field = value + doQuery(value) + } + + val items = filterList(viewModelScope) + val extraBindings = bindExtra { + it.put(BR.viewModel, this) + } + + @get:Bindable + var loading = true + private set(value) = set(value, field, { field = it }, BR.loading) + + @SuppressLint("InlinedApi") + override suspend fun doLoadWork() { + loading = true + val apps = withContext(Dispatchers.Default) { + val pm = AppContext.packageManager + val denyList = Shell.cmd("magisk --denylist ls").exec().out + .map { CmdlineListItem(it) } + val apps = pm.getInstalledApplications(MATCH_UNINSTALLED_PACKAGES).run { + asFlow() + .filter { AppContext.packageName != it.packageName } + .concurrentMap { AppProcessInfo(it, pm, denyList) } + .filter { it.processes.isNotEmpty() } + .concurrentMap { DenyListRvItem(it) } + .toCollection(ArrayList(size)) + } + apps.sort() + apps + } + items.set(apps) + doQuery(query) + } + + private fun doQuery(s: String) { + items.filter { + fun filterSystem() = isShowSystem || !it.info.isSystemApp() + + fun filterOS() = (isShowSystem && isShowOS) || it.info.isApp() + + fun filterQuery(): Boolean { + fun inName() = it.info.label.contains(s, true) + fun inPackage() = it.info.packageName.contains(s, true) + fun inProcesses() = it.processes.any { p -> p.process.name.contains(s, true) } + return inName() || inPackage() || inProcesses() + } + + (it.isChecked || (filterSystem() && filterOS())) && filterQuery() + } + loading = false + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/ConsoleItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/ConsoleItem.kt new file mode 100644 index 0000000..d0639d2 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/ConsoleItem.kt @@ -0,0 +1,38 @@ +package com.topjohnwu.magisk.ui.flash + +import android.view.View +import android.widget.TextView +import androidx.core.view.updateLayoutParams +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ItemWrapper +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.databinding.ViewAwareItem +import kotlin.math.max + +class ConsoleItem( + override val item: String +) : RvItem(), ViewAwareItem, DiffItem, ItemWrapper { + override val layoutRes = R.layout.item_console_md2 + + private var parentWidth = -1 + + override fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) { + if (parentWidth < 0) + parentWidth = (recyclerView.parent as View).width + + val view = binding.root as TextView + view.measure(0, 0) + + // We want our recyclerView at least as wide as screen + val desiredWidth = max(view.measuredWidth, parentWidth) + + view.updateLayoutParams { width = desiredWidth } + + if (recyclerView.width < desiredWidth) { + recyclerView.requestLayout() + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashFragment.kt new file mode 100644 index 0000000..7231a9b --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashFragment.kt @@ -0,0 +1,149 @@ +package com.topjohnwu.magisk.ui.flash + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ActivityInfo +import android.net.Uri +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import androidx.navigation.NavDeepLinkBuilder +import com.topjohnwu.magisk.MainDirections +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.cmp +import com.topjohnwu.magisk.databinding.FragmentFlashMd2Binding +import com.topjohnwu.magisk.ui.MainActivity +import com.topjohnwu.magisk.core.R as CoreR + +class FlashFragment : BaseFragment(), MenuProvider { + + override val layoutRes = R.layout.fragment_flash_md2 + override val viewModel by viewModel() + override val snackbarView: View get() = binding.snackbarContainer + override val snackbarAnchorView: View? + get() = if (binding.restartBtn.isShown) binding.restartBtn else super.snackbarAnchorView + + private var defaultOrientation = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.args = FlashFragmentArgs.fromBundle(requireArguments()) + } + + override fun onStart() { + super.onStart() + activity?.setTitle(CoreR.string.flash_screen_title) + + viewModel.state.observe(this) { + activity?.supportActionBar?.setSubtitle( + when (it) { + FlashViewModel.State.FLASHING -> CoreR.string.flashing + FlashViewModel.State.SUCCESS -> CoreR.string.done + FlashViewModel.State.FAILED -> CoreR.string.failure + } + ) + if (it == FlashViewModel.State.SUCCESS && viewModel.showReboot) { + binding.restartBtn.apply { + if (!this.isVisible) this.show() + if (!this.isFocused) this.requestFocus() + } + } + } + } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_flash, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return viewModel.onMenuItemClicked(item) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + defaultOrientation = activity?.requestedOrientation ?: -1 + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + if (savedInstanceState == null) { + viewModel.startFlashing() + } + } + + @SuppressLint("WrongConstant") + override fun onDestroyView() { + if (defaultOrientation != -1) { + activity?.requestedOrientation = defaultOrientation + } + super.onDestroyView() + } + + override fun onKeyEvent(event: KeyEvent): Boolean { + return when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, + KeyEvent.KEYCODE_VOLUME_DOWN -> true + else -> false + } + } + + override fun onBackPressed(): Boolean { + if (viewModel.flashing.value == true) + return true + return super.onBackPressed() + } + + override fun onPreBind(binding: FragmentFlashMd2Binding) = Unit + + companion object { + + private fun createIntent(context: Context, args: FlashFragmentArgs) = + NavDeepLinkBuilder(context) + .setGraph(R.navigation.main) + .setComponentName(MainActivity::class.java.cmp(context.packageName)) + .setDestination(R.id.flashFragment) + .setArguments(args.toBundle()) + .createPendingIntent() + + private fun flashType(isSecondSlot: Boolean) = + if (isSecondSlot) Const.Value.FLASH_INACTIVE_SLOT else Const.Value.FLASH_MAGISK + + /* Flashing is understood as installing / flashing magisk itself */ + + fun flash(isSecondSlot: Boolean) = MainDirections.actionFlashFragment( + action = flashType(isSecondSlot) + ) + + /* Patching is understood as injecting img files with magisk */ + + fun patch(uri: Uri) = MainDirections.actionFlashFragment( + action = Const.Value.PATCH_FILE, + additionalData = uri + ) + + /* Uninstalling is understood as removing magisk entirely */ + + fun uninstall() = MainDirections.actionFlashFragment( + action = Const.Value.UNINSTALL + ) + + /* Installing is understood as flashing modules / zips */ + + fun installIntent(context: Context, file: Uri) = FlashFragmentArgs( + action = Const.Value.FLASH_ZIP, + additionalData = file, + ).let { createIntent(context, it) } + + fun install(file: Uri) = MainDirections.actionFlashFragment( + action = Const.Value.FLASH_ZIP, + additionalData = file, + ) + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt new file mode 100644 index 0000000..56704d8 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt @@ -0,0 +1,122 @@ +package com.topjohnwu.magisk.ui.flash + +import android.view.MenuItem +import androidx.databinding.Bindable +import androidx.databinding.ObservableArrayList +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.ktx.reboot +import com.topjohnwu.magisk.core.ktx.synchronized +import com.topjohnwu.magisk.core.ktx.timeFormatStandard +import com.topjohnwu.magisk.core.ktx.toTime +import com.topjohnwu.magisk.core.tasks.FlashZip +import com.topjohnwu.magisk.core.tasks.MagiskInstaller +import com.topjohnwu.magisk.core.utils.MediaStoreUtils +import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.events.SnackbarEvent +import com.topjohnwu.superuser.CallbackList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class FlashViewModel : BaseViewModel() { + + enum class State { + FLASHING, SUCCESS, FAILED + } + + private val _state = MutableLiveData(State.FLASHING) + val state: LiveData get() = _state + val flashing = state.map { it == State.FLASHING } + + @get:Bindable + var showReboot = Info.isRooted + set(value) = set(value, field, { field = it }, BR.showReboot) + + val items = ObservableArrayList() + lateinit var args: FlashFragmentArgs + + private val logItems = mutableListOf().synchronized() + private val outItems = object : CallbackList() { + override fun onAddElement(e: String?) { + e ?: return + items.add(ConsoleItem(e)) + logItems.add(e) + } + } + + fun startFlashing() { + val (action, uri) = args + + viewModelScope.launch { + val result = when (action) { + Const.Value.FLASH_ZIP -> { + uri ?: return@launch + FlashZip(uri, outItems, logItems).exec() + } + Const.Value.UNINSTALL -> { + showReboot = false + MagiskInstaller.Uninstall(outItems, logItems).exec() + } + Const.Value.FLASH_MAGISK -> { + if (Info.isEmulator) + MagiskInstaller.Emulator(outItems, logItems).exec() + else + MagiskInstaller.Direct(outItems, logItems).exec() + } + Const.Value.FLASH_INACTIVE_SLOT -> { + showReboot = false + MagiskInstaller.SecondSlot(outItems, logItems).exec() + } + Const.Value.PATCH_FILE -> { + uri ?: return@launch + showReboot = false + MagiskInstaller.Patch(uri, outItems, logItems).exec() + } + else -> { + back() + return@launch + } + } + onResult(result) + } + } + + private fun onResult(success: Boolean) { + _state.value = if (success) State.SUCCESS else State.FAILED + } + + fun onMenuItemClicked(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_save -> savePressed() + } + return true + } + + private fun savePressed() = withExternalRW { + viewModelScope.launch(Dispatchers.IO) { + val name = "magisk_install_log_%s.log".format( + System.currentTimeMillis().toTime(timeFormatStandard) + ) + val file = MediaStoreUtils.getFile(name) + file.uri.outputStream().bufferedWriter().use { writer -> + synchronized(logItems) { + logItems.forEach { + writer.write(it) + writer.newLine() + } + } + } + SnackbarEvent(file.toString()).publish() + } + } + + fun restartPressed() = reboot() +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/DeveloperItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/DeveloperItem.kt new file mode 100644 index 0000000..9da7c50 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/DeveloperItem.kt @@ -0,0 +1,127 @@ +package com.topjohnwu.magisk.ui.home + +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.core.R as CoreR + +interface Dev { + val name: String +} + +private interface JohnImpl : Dev { + override val name get() = "topjohnwu" +} + +private interface VvbImpl : Dev { + override val name get() = "vvb2060" +} + +private interface YUImpl : Dev { + override val name get() = "yujincheng08" +} + +private interface RikkaImpl : Dev { + override val name get() = "RikkaW" +} + +private interface CanyieImpl : Dev { + override val name get() = "canyie" +} + +sealed class DeveloperItem : Dev { + + abstract val items: List + val handle get() = "@${name}" + + object John : DeveloperItem(), JohnImpl { + override val items = + listOf( + object : IconLink.Twitter(), JohnImpl {}, + IconLink.Github.Project + ) + } + + object Vvb : DeveloperItem(), VvbImpl { + override val items = + listOf( + object : IconLink.Twitter(), VvbImpl {}, + object : IconLink.Github.User(), VvbImpl {} + ) + } + + object YU : DeveloperItem(), YUImpl { + override val items = + listOf( + object : IconLink.Twitter() { override val name = "shanasaimoe" }, + object : IconLink.Github.User(), YUImpl {}, + object : IconLink.Sponsor(), YUImpl {} + ) + } + + object Rikka : DeveloperItem(), RikkaImpl { + override val items = + listOf( + object : IconLink.Twitter() { override val name = "rikkawww" }, + object : IconLink.Github.User(), RikkaImpl {} + ) + } + + object Canyie : DeveloperItem(), CanyieImpl { + override val items = + listOf( + object : IconLink.Twitter() { override val name = "canyie2977" }, + object : IconLink.Github.User(), CanyieImpl {} + ) + } +} + +sealed class IconLink : RvItem() { + + abstract val icon: Int + abstract val title: Int + abstract val link: String + + override val layoutRes get() = R.layout.item_icon_link + + abstract class PayPal : IconLink(), Dev { + override val icon get() = CoreR.drawable.ic_paypal + override val title get() = CoreR.string.paypal + override val link get() = "https://paypal.me/$name" + + object Project : PayPal() { + override val name: String get() = "magiskdonate" + } + } + + object Patreon : IconLink() { + override val icon get() = CoreR.drawable.ic_patreon + override val title get() = CoreR.string.patreon + override val link get() = Const.Url.PATREON_URL + } + + abstract class Twitter : IconLink(), Dev { + override val icon get() = CoreR.drawable.ic_twitter + override val title get() = CoreR.string.twitter + override val link get() = "https://twitter.com/$name" + } + + abstract class Github : IconLink() { + override val icon get() = CoreR.drawable.ic_github + override val title get() = CoreR.string.github + + abstract class User : Github(), Dev { + override val link get() = "https://github.com/$name" + } + + object Project : Github() { + override val link get() = Const.Url.SOURCE_CODE_URL + } + } + + abstract class Sponsor : IconLink(), Dev { + override val icon get() = CoreR.drawable.ic_favorite + override val title get() = CoreR.string.github + override val link get() = "https://github.com/sponsors/$name" + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt new file mode 100644 index 0000000..1b5a76f --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt @@ -0,0 +1,90 @@ +package com.topjohnwu.magisk.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.MenuProvider +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.download.DownloadEngine +import com.topjohnwu.magisk.databinding.FragmentHomeMd2Binding +import com.topjohnwu.magisk.core.R as CoreR +import androidx.navigation.findNavController +import com.topjohnwu.magisk.arch.NavigationActivity + +class HomeFragment : BaseFragment(), MenuProvider { + + override val layoutRes = R.layout.fragment_home_md2 + override val viewModel by viewModel() + + override fun onStart() { + super.onStart() + activity?.setTitle(CoreR.string.section_home) + DownloadEngine.observeProgress(this, viewModel::onProgressUpdate) + } + + private fun checkTitle(text: TextView, icon: ImageView) { + text.post { + if (text.layout?.getEllipsisCount(0) != 0) { + with (icon) { + layoutParams.width = 0 + layoutParams.height = 0 + requestLayout() + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + + // If titles are squished, hide icons + with(binding.homeMagiskWrapper) { + checkTitle(homeMagiskTitle, homeMagiskIcon) + } + with(binding.homeManagerWrapper) { + checkTitle(homeManagerTitle, homeManagerIcon) + } + + return binding.root + } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_home_md2, menu) + if (!Info.isRooted) + menu.removeItem(R.id.action_reboot) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_settings -> + activity?.let { + NavigationActivity.navigate( + HomeFragmentDirections.actionHomeFragmentToSettingsFragment(), + it.findNavController(R.id.main_nav_host), + it.contentResolver, + ) + } + R.id.action_reboot -> activity?.let { RebootMenu.inflate(it).show() } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + override fun onResume() { + super.onResume() + viewModel.stateManagerProgress = 0 + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..a82b6cc --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt @@ -0,0 +1,166 @@ +package com.topjohnwu.magisk.ui.home + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.core.net.toUri +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.ActivityExecutor +import com.topjohnwu.magisk.arch.AsyncLoadViewModel +import com.topjohnwu.magisk.arch.ContextExecutor +import com.topjohnwu.magisk.arch.UIActivity +import com.topjohnwu.magisk.arch.ViewEvent +import com.topjohnwu.magisk.core.BuildConfig +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.download.Subject +import com.topjohnwu.magisk.core.download.Subject.App +import com.topjohnwu.magisk.core.ktx.await +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.repository.NetworkService +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.dialog.EnvFixDialog +import com.topjohnwu.magisk.dialog.ManagerInstallDialog +import com.topjohnwu.magisk.dialog.UninstallDialog +import com.topjohnwu.magisk.events.SnackbarEvent +import com.topjohnwu.magisk.utils.asText +import com.topjohnwu.superuser.Shell +import kotlin.math.roundToInt +import com.topjohnwu.magisk.core.R as CoreR + +class HomeViewModel( + private val svc: NetworkService +) : AsyncLoadViewModel() { + + enum class State { + LOADING, INVALID, OUTDATED, UP_TO_DATE + } + + val magiskTitleBarrierIds = + intArrayOf(R.id.home_magisk_icon, R.id.home_magisk_title, R.id.home_magisk_button) + val appTitleBarrierIds = + intArrayOf(R.id.home_manager_icon, R.id.home_manager_title, R.id.home_manager_button) + + @get:Bindable + var isNoticeVisible = Config.safetyNotice + set(value) = set(value, field, { field = it }, BR.noticeVisible) + + val magiskState + get() = when { + Info.isRooted && Info.env.isUnsupported -> State.OUTDATED + !Info.env.isActive -> State.INVALID + Info.env.versionCode < BuildConfig.APP_VERSION_CODE -> State.OUTDATED + else -> State.UP_TO_DATE + } + + @get:Bindable + var appState = State.LOADING + set(value) = set(value, field, { field = it }, BR.appState) + + val magiskInstalledVersion + get() = Info.env.run { + if (isActive) + ("$versionString ($versionCode)" + if (isDebug) " (D)" else "").asText() + else + CoreR.string.not_available.asText() + } + + @get:Bindable + var managerRemoteVersion = CoreR.string.loading.asText() + set(value) = set(value, field, { field = it }, BR.managerRemoteVersion) + + val managerInstalledVersion + get() = "${BuildConfig.APP_VERSION_NAME} (${BuildConfig.APP_VERSION_CODE})" + + if (BuildConfig.DEBUG) " (D)" else "" + + @get:Bindable + var stateManagerProgress = 0 + set(value) = set(value, field, { field = it }, BR.stateManagerProgress) + + val extraBindings = bindExtra { + it.put(BR.viewModel, this) + } + + companion object { + private var checkedEnv = false + } + + override suspend fun doLoadWork() { + appState = State.LOADING + Info.fetchUpdate(svc)?.apply { + appState = when { + BuildConfig.APP_VERSION_CODE < versionCode -> State.OUTDATED + else -> State.UP_TO_DATE + } + + val isDebug = Config.updateChannel == Config.Value.DEBUG_CHANNEL + managerRemoteVersion = + ("$version (${versionCode})" + if (isDebug) " (D)" else "").asText() + } ?: run { + appState = State.INVALID + managerRemoteVersion = CoreR.string.not_available.asText() + } + ensureEnv() + } + + override fun onNetworkChanged(network: Boolean) = startLoading() + + fun onProgressUpdate(progress: Float, subject: Subject) { + if (subject is App) + stateManagerProgress = progress.times(100f).roundToInt() + } + + fun onLinkPressed(link: String) = object : ViewEvent(), ContextExecutor { + override fun invoke(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, link.toUri()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + context.toast(CoreR.string.open_link_failed_toast, Toast.LENGTH_SHORT) + } + } + }.publish() + + fun onDeletePressed() = UninstallDialog().show() + + fun onManagerPressed() = when (appState) { + State.LOADING -> SnackbarEvent(CoreR.string.loading).publish() + State.INVALID -> SnackbarEvent(CoreR.string.no_connection).publish() + else -> withExternalRW { + withInstallPermission { + ManagerInstallDialog().show() + } + } + } + + fun onMagiskPressed() = withExternalRW { + HomeFragmentDirections.actionHomeFragmentToInstallFragment().navigate() + } + + fun hideNotice() { + Config.safetyNotice = false + isNoticeVisible = false + } + + private suspend fun ensureEnv() { + if (magiskState == State.INVALID || checkedEnv) return + val cmd = "env_check ${Info.env.versionString} ${Info.env.versionCode}" + val code = Shell.cmd(cmd).await().code + if (code != 0) { + EnvFixDialog(this, code).show() + } + checkedEnv = true + } + + val showTest = false + fun onTestPressed() = object : ViewEvent(), ActivityExecutor { + override fun invoke(activity: UIActivity<*>) { + /* Entry point to trigger test events within the app */ + } + }.publish() +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/RebootMenu.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/RebootMenu.kt new file mode 100644 index 0000000..467a290 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/home/RebootMenu.kt @@ -0,0 +1,52 @@ +package com.topjohnwu.magisk.ui.home + +import android.app.Activity +import android.os.Build +import android.os.PowerManager +import android.view.ContextThemeWrapper +import android.view.MenuItem +import android.widget.PopupMenu +import androidx.core.content.getSystemService +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.ktx.reboot as systemReboot + +object RebootMenu { + + private fun reboot(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_reboot_normal -> systemReboot() + R.id.action_reboot_userspace -> systemReboot("userspace") + R.id.action_reboot_bootloader -> systemReboot("bootloader") + R.id.action_reboot_download -> systemReboot("download") + R.id.action_reboot_edl -> systemReboot("edl") + R.id.action_reboot_recovery -> systemReboot("recovery") + R.id.action_reboot_safe_mode -> { + val status = !item.isChecked + item.isChecked = status + Config.bootloop = if (status) 2 else 0 + } + else -> Unit + } + return true + } + + fun inflate(activity: Activity): PopupMenu { + val themeWrapper = ContextThemeWrapper(activity, R.style.Foundation_PopupMenu) + val menu = PopupMenu(themeWrapper, activity.findViewById(R.id.action_reboot)) + activity.menuInflater.inflate(R.menu.menu_reboot, menu.menu) + menu.setOnMenuItemClickListener(RebootMenu::reboot) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + activity.getSystemService()?.isRebootingUserspaceSupported == true) { + menu.menu.findItem(R.id.action_reboot_userspace).isVisible = true + } + if (Const.Version.atLeast_28_0()) { + menu.menu.findItem(R.id.action_reboot_safe_mode).isChecked = Config.bootloop >= 2 + } else { + menu.menu.findItem(R.id.action_reboot_safe_mode).isVisible = false + } + return menu + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt new file mode 100644 index 0000000..2b7307d --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt @@ -0,0 +1,18 @@ +package com.topjohnwu.magisk.ui.install + +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.databinding.FragmentInstallMd2Binding +import com.topjohnwu.magisk.core.R as CoreR + +class InstallFragment : BaseFragment() { + + override val layoutRes = R.layout.fragment_install_md2 + override val viewModel by viewModel() + + override fun onStart() { + super.onStart() + requireActivity().setTitle(CoreR.string.install) + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt new file mode 100644 index 0000000..6508ce4 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt @@ -0,0 +1,147 @@ +package com.topjohnwu.magisk.ui.install + +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.text.Spanned +import android.text.SpannedString +import android.widget.Toast +import androidx.databinding.Bindable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_CODE +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.base.ContentResultCallback +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.repository.NetworkService +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.dialog.SecondSlotWarningDialog +import com.topjohnwu.magisk.events.GetContentEvent +import com.topjohnwu.magisk.ui.flash.FlashFragment +import io.noties.markwon.Markwon +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import timber.log.Timber +import java.io.File +import java.io.IOException +import com.topjohnwu.magisk.core.R as CoreR + +class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel() { + + val isRooted get() = Info.isRooted + val skipOptions = Info.isEmulator || (Info.isSAR && !Info.isFDE && Info.ramdisk) + val noSecondSlot = !isRooted || !Info.isAB || Info.isEmulator + + @get:Bindable + var step = if (skipOptions) 1 else 0 + set(value) = set(value, field, { field = it }, BR.step) + + private var methodId = -1 + + @get:Bindable + var method + get() = methodId + set(value) = set(value, methodId, { methodId = it }, BR.method) { + when (it) { + R.id.method_patch -> { + GetContentEvent("*/*", UriCallback()).publish() + } + R.id.method_inactive_slot -> { + SecondSlotWarningDialog().show() + } + } + } + + val data: LiveData get() = uri + + @get:Bindable + var notes: Spanned = SpannedString("") + set(value) = set(value, field, { field = it }, BR.notes) + + init { + viewModelScope.launch(Dispatchers.IO) { + try { + val noteFile = File(AppContext.cacheDir, "${APP_VERSION_CODE}.md") + val noteText = when { + noteFile.exists() -> noteFile.readText() + else -> { + val note = svc.fetchUpdate(APP_VERSION_CODE)?.note.orEmpty() + if (note.isEmpty()) return@launch + noteFile.writeText(note) + note + } + } + val spanned = markwon.toMarkdown(noteText) + withContext(Dispatchers.Main) { + notes = spanned + } + } catch (e: IOException) { + Timber.e(e) + } + } + } + + fun install() { + when (method) { + R.id.method_patch -> FlashFragment.patch(data.value!!).navigate(true) + R.id.method_direct -> FlashFragment.flash(false).navigate(true) + R.id.method_inactive_slot -> FlashFragment.flash(true).navigate(true) + else -> error("Unknown value") + } + } + + override fun onSaveState(state: Bundle) { + state.putParcelable( + INSTALL_STATE_KEY, InstallState( + methodId, + step, + Config.keepVerity, + Config.keepEnc, + Config.recovery + ) + ) + } + + override fun onRestoreState(state: Bundle) { + state.getParcelable(INSTALL_STATE_KEY)?.let { + methodId = it.method + step = it.step + Config.keepVerity = it.keepVerity + Config.keepEnc = it.keepEnc + Config.recovery = it.recovery + } + } + + @Parcelize + class UriCallback : ContentResultCallback { + override fun onActivityLaunch() { + AppContext.toast(CoreR.string.patch_file_msg, Toast.LENGTH_LONG) + } + + override fun onActivityResult(result: Uri) { + uri.value = result + } + } + + @Parcelize + class InstallState( + val method: Int, + val step: Int, + val keepVerity: Boolean, + val keepEnc: Boolean, + val recovery: Boolean, + ) : Parcelable + + companion object { + private const val INSTALL_STATE_KEY = "install_state" + private val uri = MutableLiveData() + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogFragment.kt new file mode 100644 index 0000000..182d9cf --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogFragment.kt @@ -0,0 +1,97 @@ +package com.topjohnwu.magisk.ui.log + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.widget.HorizontalScrollView +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.databinding.FragmentLogMd2Binding +import com.topjohnwu.magisk.ui.MainActivity +import com.topjohnwu.magisk.utils.AccessibilityUtils +import com.topjohnwu.magisk.utils.MotionRevealHelper +import rikka.recyclerview.addEdgeSpacing +import rikka.recyclerview.addItemSpacing +import rikka.recyclerview.fixEdgeEffect +import com.topjohnwu.magisk.core.R as CoreR + +class LogFragment : BaseFragment(), MenuProvider { + + override val layoutRes = R.layout.fragment_log_md2 + override val viewModel by viewModel() + override val snackbarView: View? + get() = if (isMagiskLogVisible) binding.logFilterSuperuser.snackbarContainer + else super.snackbarView + override val snackbarAnchorView get() = binding.logFilterToggle + + private var actionSave: MenuItem? = null + private var isMagiskLogVisible + get() = binding.logFilter.isVisible + set(value) { + MotionRevealHelper.withViews(binding.logFilter, binding.logFilterToggle, value) + actionSave?.isVisible = !value + with(activity as MainActivity) { + invalidateToolbar() + requestNavigationHidden(value) + setDisplayHomeAsUpEnabled(value) + } + } + + override fun onStart() { + super.onStart() + activity?.setTitle(CoreR.string.logs) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.logFilterToggle.setOnClickListener { + isMagiskLogVisible = true + } + + binding.logFilterSuperuser.logSuperuser.apply { + addEdgeSpacing(bottom = R.dimen.l1) + addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1) + fixEdgeEffect() + } + + if (!AccessibilityUtils.isAnimationEnabled(requireContext().contentResolver)) { + val scrollView = view.findViewById(R.id.log_scroll_magisk) + scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER) + } + } + + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_log_md2, menu) + actionSave = menu.findItem(R.id.action_save)?.also { + it.isVisible = !isMagiskLogVisible + } + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_save -> viewModel.saveMagiskLog() + R.id.action_clear -> + if (!isMagiskLogVisible) viewModel.clearMagiskLog() + else viewModel.clearLog() + } + return super.onOptionsItemSelected(item) + } + + + override fun onPreBind(binding: FragmentLogMd2Binding) = Unit + + override fun onBackPressed(): Boolean { + if (binding.logFilter.isVisible) { + isMagiskLogVisible = false + return true + } + return super.onBackPressed() + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogRvItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogRvItem.kt new file mode 100644 index 0000000..21bffb3 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogRvItem.kt @@ -0,0 +1,28 @@ +package com.topjohnwu.magisk.ui.log + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textview.MaterialTextView +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ItemWrapper +import com.topjohnwu.magisk.databinding.ObservableRvItem +import com.topjohnwu.magisk.databinding.ViewAwareItem + +class LogRvItem( + override val item: String +) : ObservableRvItem(), DiffItem, ItemWrapper, ViewAwareItem { + + override val layoutRes = R.layout.item_log_textview + + override fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) { + val view = binding.root as MaterialTextView + view.measure(0, 0) + val desiredWidth = view.measuredWidth + val layoutParams = view.layoutParams + layoutParams.width = desiredWidth + if (recyclerView.width < desiredWidth) { + recyclerView.requestLayout() + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt new file mode 100644 index 0000000..928a6f8 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt @@ -0,0 +1,114 @@ +package com.topjohnwu.magisk.ui.log + +import android.system.Os +import androidx.databinding.Bindable +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.arch.AsyncLoadViewModel +import com.topjohnwu.magisk.core.BuildConfig +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.ktx.timeFormatStandard +import com.topjohnwu.magisk.core.ktx.toTime +import com.topjohnwu.magisk.core.repository.LogRepository +import com.topjohnwu.magisk.core.utils.MediaStoreUtils +import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.databinding.diffList +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.events.SnackbarEvent +import com.topjohnwu.magisk.view.TextItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileInputStream + +class LogViewModel( + private val repo: LogRepository +) : AsyncLoadViewModel() { + @get:Bindable + var loading = true + private set(value) = set(value, field, { field = it }, BR.loading) + + // --- empty view + + val itemEmpty = TextItem(R.string.log_data_none) + val itemMagiskEmpty = TextItem(R.string.log_data_magisk_none) + + // --- su log + + val items = diffList() + val extraBindings = bindExtra { + it.put(BR.viewModel, this) + } + + // --- magisk log + val logs = diffList() + var magiskLogRaw = " " + + override suspend fun doLoadWork() { + loading = true + + val (suLogs, suDiff) = withContext(Dispatchers.Default) { + magiskLogRaw = repo.fetchMagiskLogs() + val newLogs = magiskLogRaw.split('\n').map { LogRvItem(it) } + logs.update(newLogs) + val suLogs = repo.fetchSuLogs().map { SuLogRvItem(it) } + suLogs to items.calculateDiff(suLogs) + } + + items.firstOrNull()?.isTop = false + items.lastOrNull()?.isBottom = false + items.update(suLogs, suDiff) + items.firstOrNull()?.isTop = true + items.lastOrNull()?.isBottom = true + loading = false + } + + fun saveMagiskLog() = withExternalRW { + viewModelScope.launch(Dispatchers.IO) { + val filename = "magisk_log_%s.log".format( + System.currentTimeMillis().toTime(timeFormatStandard)) + val logFile = MediaStoreUtils.getFile(filename) + logFile.uri.outputStream().bufferedWriter().use { file -> + file.write("---Detected Device Info---\n\n") + file.write("isAB=${Info.isAB}\n") + file.write("isSAR=${Info.isSAR}\n") + file.write("ramdisk=${Info.ramdisk}\n") + val uname = Os.uname() + file.write("kernel=${uname.sysname} ${uname.machine} ${uname.release} ${uname.version}\n") + + file.write("\n\n---System Properties---\n\n") + ProcessBuilder("getprop").start() + .inputStream.reader().use { it.copyTo(file) } + + file.write("\n\n---Environment Variables---\n\n") + System.getenv().forEach { (key, value) -> file.write("${key}=${value}\n") } + + file.write("\n\n---System MountInfo---\n\n") + FileInputStream("/proc/self/mountinfo").reader().use { it.copyTo(file) } + + file.write("\n---Magisk Logs---\n") + file.write("${Info.env.versionString} (${Info.env.versionCode})\n\n") + if (Info.env.isActive) file.write(magiskLogRaw) + + file.write("\n---Manager Logs---\n") + file.write("${BuildConfig.APP_VERSION_NAME} (${BuildConfig.APP_VERSION_CODE})\n\n") + ProcessBuilder("logcat", "-d").start() + .inputStream.reader().use { it.copyTo(file) } + } + SnackbarEvent(logFile.toString()).publish() + } + } + + fun clearMagiskLog() = repo.clearMagiskLogs { + SnackbarEvent(R.string.logs_cleared).publish() + startLoading() + } + + fun clearLog() = viewModelScope.launch { + repo.clearLogs() + SnackbarEvent(R.string.logs_cleared).publish() + startLoading() + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/SuLogRvItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/SuLogRvItem.kt new file mode 100644 index 0000000..b9395c1 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/SuLogRvItem.kt @@ -0,0 +1,54 @@ +package com.topjohnwu.magisk.ui.log + +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.ktx.timeDateFormat +import com.topjohnwu.magisk.core.ktx.toTime +import com.topjohnwu.magisk.core.model.su.SuLog +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ObservableRvItem +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.core.R as CoreR + +class SuLogRvItem(val log: SuLog) : ObservableRvItem(), DiffItem { + + override val layoutRes = R.layout.item_log_access_md2 + + val info = genInfo() + + @get:Bindable + var isTop = false + set(value) = set(value, field, { field = it }, BR.top) + + @get:Bindable + var isBottom = false + set(value) = set(value, field, { field = it }, BR.bottom) + + override fun itemSameAs(other: SuLogRvItem) = log.appName == other.log.appName + + private fun genInfo(): String { + val res = AppContext.resources + val sb = StringBuilder() + val date = log.time.toTime(timeDateFormat) + val toUid = res.getString(CoreR.string.target_uid, log.toUid) + val fromPid = res.getString(CoreR.string.pid, log.fromPid) + sb.append("$date\n$toUid $fromPid") + if (log.target != -1) { + val pid = if (log.target == 0) "magiskd" else log.target.toString() + val target = res.getString(CoreR.string.target_pid, pid) + sb.append(" $target") + } + if (log.context.isNotEmpty()) { + val context = res.getString(CoreR.string.selinux_context, log.context) + sb.append("\n$context") + } + if (log.gids.isNotEmpty()) { + val gids = res.getString(CoreR.string.supp_group, log.gids) + sb.append("\n$gids") + } + sb.append("\n${log.command}") + return sb.toString() + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionFragment.kt new file mode 100644 index 0000000..e3055a7 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionFragment.kt @@ -0,0 +1,108 @@ +package com.topjohnwu.magisk.ui.module + +import android.annotation.SuppressLint +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewTreeObserver +import android.widget.Toast +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.databinding.FragmentActionMd2Binding +import com.topjohnwu.magisk.core.R as CoreR + +class ActionFragment : BaseFragment(), MenuProvider { + + override val layoutRes = R.layout.fragment_action_md2 + override val viewModel by viewModel() + override val snackbarView: View get() = binding.snackbarContainer + + private var defaultOrientation = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.args = ActionFragmentArgs.fromBundle(requireArguments()) + } + + override fun onStart() { + super.onStart() + activity?.setTitle(viewModel.args.name) + binding.closeBtn.setOnClickListener { + activity?.onBackPressed() + } + + viewModel.state.observe(this) { + if (it != ActionViewModel.State.RUNNING) { + binding.closeBtn.apply { + if (!this.isVisible) this.show() + if (!this.isFocused) this.requestFocus() + } + } + if (it != ActionViewModel.State.SUCCESS) return@observe + view?.viewTreeObserver?.addOnWindowFocusChangeListener( + object : ViewTreeObserver.OnWindowFocusChangeListener { + override fun onWindowFocusChanged(hasFocus: Boolean) { + if (hasFocus) return + view?.viewTreeObserver?.removeOnWindowFocusChangeListener(this) + view?.context?.apply { + toast( + getString(CoreR.string.done_action, viewModel.args.name), + Toast.LENGTH_SHORT + ) + } + viewModel.back() + } + } + ) + } + } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_flash, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return viewModel.onMenuItemClicked(item) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + defaultOrientation = activity?.requestedOrientation ?: -1 + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + if (savedInstanceState == null) { + viewModel.startRunAction() + } + } + + @SuppressLint("WrongConstant") + override fun onDestroyView() { + if (defaultOrientation != -1) { + activity?.requestedOrientation = defaultOrientation + } + super.onDestroyView() + } + + override fun onKeyEvent(event: KeyEvent): Boolean { + return when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> true + + else -> false + } + } + + override fun onBackPressed(): Boolean { + if (viewModel.state.value == ActionViewModel.State.RUNNING) return true + return super.onBackPressed() + } + + override fun onPreBind(binding: FragmentActionMd2Binding) = Unit +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt new file mode 100644 index 0000000..2591aad --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt @@ -0,0 +1,88 @@ +package com.topjohnwu.magisk.ui.module + +import android.view.MenuItem +import androidx.databinding.ObservableArrayList +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.core.ktx.synchronized +import com.topjohnwu.magisk.core.ktx.timeFormatStandard +import com.topjohnwu.magisk.core.ktx.toTime +import com.topjohnwu.magisk.core.utils.MediaStoreUtils +import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream +import com.topjohnwu.magisk.events.SnackbarEvent +import com.topjohnwu.magisk.ui.flash.ConsoleItem +import com.topjohnwu.superuser.CallbackList +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.IOException + +class ActionViewModel : BaseViewModel() { + + enum class State { + RUNNING, SUCCESS, FAILED + } + + private val _state = MutableLiveData(State.RUNNING) + val state: LiveData get() = _state + + val items = ObservableArrayList() + lateinit var args: ActionFragmentArgs + + private val logItems = mutableListOf().synchronized() + private val outItems = object : CallbackList() { + override fun onAddElement(e: String?) { + e ?: return + items.add(ConsoleItem(e)) + logItems.add(e) + } + } + + fun startRunAction() = viewModelScope.launch { + onResult(withContext(Dispatchers.IO) { + try { + Shell.cmd("run_action \'${args.id}\'") + .to(outItems, logItems) + .exec().isSuccess + } catch (e: IOException) { + Timber.e(e) + false + } + }) + } + + private fun onResult(success: Boolean) { + _state.value = if (success) State.SUCCESS else State.FAILED + } + + fun onMenuItemClicked(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_save -> savePressed() + } + return true + } + + private fun savePressed() = withExternalRW { + viewModelScope.launch(Dispatchers.IO) { + val name = "%s_action_log_%s.log".format( + args.name, + System.currentTimeMillis().toTime(timeFormatStandard) + ) + val file = MediaStoreUtils.getFile(name) + file.uri.outputStream().bufferedWriter().use { writer -> + synchronized(logItems) { + logItems.forEach { + writer.write(it) + writer.newLine() + } + } + } + SnackbarEvent(file.toString()).publish() + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleFragment.kt new file mode 100644 index 0000000..c2e7b3f --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleFragment.kt @@ -0,0 +1,45 @@ +package com.topjohnwu.magisk.ui.module + +import android.os.Bundle +import android.view.View +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.utils.MediaStoreUtils.displayName +import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding +import rikka.recyclerview.addEdgeSpacing +import rikka.recyclerview.addInvalidateItemDecorationsObserver +import rikka.recyclerview.addItemSpacing +import rikka.recyclerview.fixEdgeEffect +import com.topjohnwu.magisk.core.R as CoreR + +class ModuleFragment : BaseFragment() { + + override val layoutRes = R.layout.fragment_module_md2 + override val viewModel by viewModel() + + override fun onStart() { + super.onStart() + activity?.title = resources.getString(CoreR.string.modules) + viewModel.data.observe(this) { + it ?: return@observe + val displayName = runCatching { it.displayName }.getOrNull() ?: return@observe + viewModel.requestInstallLocalModule(it, displayName) + viewModel.data.value = null + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.moduleList.apply { + addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1) + addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1) + fixEdgeEffect() + post { addInvalidateItemDecorationsObserver() } + } + } + + override fun onPreBind(binding: FragmentModuleMd2Binding) = Unit + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleRvItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleRvItem.kt new file mode 100644 index 0000000..97c164d --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleRvItem.kt @@ -0,0 +1,78 @@ +package com.topjohnwu.magisk.ui.module + +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.model.module.LocalModule +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ItemWrapper +import com.topjohnwu.magisk.databinding.ObservableRvItem +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.utils.TextHolder +import com.topjohnwu.magisk.utils.asText +import com.topjohnwu.magisk.core.R as CoreR + +object InstallModule : RvItem(), DiffItem { + override val layoutRes = R.layout.item_module_download +} + +class LocalModuleRvItem( + override val item: LocalModule +) : ObservableRvItem(), DiffItem, ItemWrapper { + + override val layoutRes = R.layout.item_module_md2 + + val showNotice: Boolean + val showAction: Boolean + val noticeText: TextHolder + + init { + val isZygisk = item.isZygisk + val isRiru = item.isRiru + val zygiskUnloaded = isZygisk && item.zygiskUnloaded + + showNotice = zygiskUnloaded || + (Info.isZygiskEnabled && isRiru) || + (!Info.isZygiskEnabled && isZygisk) + showAction = item.hasAction && !showNotice + noticeText = + when { + zygiskUnloaded -> CoreR.string.zygisk_module_unloaded.asText() + isRiru -> CoreR.string.suspend_text_riru.asText(CoreR.string.zygisk.asText()) + else -> CoreR.string.suspend_text_zygisk.asText(CoreR.string.zygisk.asText()) + } + } + + @get:Bindable + var isEnabled = item.enable + set(value) = set(value, field, { field = it }, BR.enabled, BR.updateReady) { + item.enable = value + } + + @get:Bindable + var isRemoved = item.remove + set(value) = set(value, field, { field = it }, BR.removed, BR.updateReady) { + item.remove = value + } + + @get:Bindable + val showUpdate get() = item.updateInfo != null + + @get:Bindable + val updateReady get() = item.outdated && !isRemoved && isEnabled + + val isUpdated = item.updated + + fun fetchedUpdateInfo() { + notifyPropertyChanged(BR.showUpdate) + notifyPropertyChanged(BR.updateReady) + } + + fun delete() { + isRemoved = !isRemoved + } + + override fun itemSameAs(other: LocalModuleRvItem): Boolean = item.id == other.item.id +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt new file mode 100644 index 0000000..8632063 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt @@ -0,0 +1,108 @@ +package com.topjohnwu.magisk.ui.module + +import android.net.Uri +import androidx.databinding.Bindable +import androidx.lifecycle.MutableLiveData +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.MainDirections +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.AsyncLoadViewModel +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.base.ContentResultCallback +import com.topjohnwu.magisk.core.model.module.LocalModule +import com.topjohnwu.magisk.core.model.module.OnlineModule +import com.topjohnwu.magisk.databinding.MergeObservableList +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.databinding.diffList +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.dialog.LocalModuleInstallDialog +import com.topjohnwu.magisk.dialog.OnlineModuleInstallDialog +import com.topjohnwu.magisk.events.GetContentEvent +import com.topjohnwu.magisk.events.SnackbarEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import com.topjohnwu.magisk.core.R as CoreR + +class ModuleViewModel : AsyncLoadViewModel() { + + val bottomBarBarrierIds = intArrayOf(R.id.module_update, R.id.module_remove) + + private val itemsInstalled = diffList() + + val items = MergeObservableList() + val extraBindings = bindExtra { + it.put(BR.viewModel, this) + } + + val data get() = uri + + @get:Bindable + var loading = true + private set(value) = set(value, field, { field = it }, BR.loading) + + override suspend fun doLoadWork() { + loading = true + val moduleLoaded = Info.env.isActive && + withContext(Dispatchers.IO) { LocalModule.loaded() } + if (moduleLoaded) { + loadInstalled() + if (items.isEmpty()) { + items.insertItem(InstallModule) + .insertList(itemsInstalled) + } + } + loading = false + loadUpdateInfo() + } + + override fun onNetworkChanged(network: Boolean) = startLoading() + + private suspend fun loadInstalled() { + withContext(Dispatchers.Default) { + val installed = LocalModule.installed().map { LocalModuleRvItem(it) } + itemsInstalled.update(installed) + } + } + + private suspend fun loadUpdateInfo() { + withContext(Dispatchers.IO) { + itemsInstalled.forEach { + if (it.item.fetch()) + it.fetchedUpdateInfo() + } + } + } + + fun downloadPressed(item: OnlineModule?) = + if (item != null && Info.isConnected.value == true) { + withExternalRW { OnlineModuleInstallDialog(item).show() } + } else { + SnackbarEvent(CoreR.string.no_connection).publish() + } + + fun installPressed() = withExternalRW { + GetContentEvent("application/zip", UriCallback()).publish() + } + + fun requestInstallLocalModule(uri: Uri, displayName: String) { + LocalModuleInstallDialog(this, uri, displayName).show() + } + + @Parcelize + class UriCallback : ContentResultCallback { + override fun onActivityResult(result: Uri) { + uri.value = result + } + } + + fun runAction(id: String, name: String) { + MainDirections.actionActionFragment(id, name).navigate() + } + + companion object { + private val uri = MutableLiveData() + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/BaseSettingsItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/BaseSettingsItem.kt new file mode 100644 index 0000000..fd45636 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/BaseSettingsItem.kt @@ -0,0 +1,145 @@ +package com.topjohnwu.magisk.ui.settings + +import android.content.Context +import android.content.res.Resources +import android.view.View +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.ktx.activity +import com.topjohnwu.magisk.databinding.ObservableRvItem +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.utils.TextHolder +import com.topjohnwu.magisk.view.MagiskDialog + +sealed class BaseSettingsItem : ObservableRvItem() { + + interface Handler { + fun onItemPressed(view: View, item: BaseSettingsItem, andThen: () -> Unit) + fun onItemAction(view: View, item: BaseSettingsItem) + } + + override val layoutRes get() = R.layout.item_settings + + open val icon: Int get() = 0 + open val title: TextHolder get() = TextHolder.EMPTY + @get:Bindable + open val description: TextHolder get() = TextHolder.EMPTY + @get:Bindable + var isEnabled = true + set(value) = set(value, field, { field = it }, BR.enabled, BR.description) + + open fun onPressed(view: View, handler: Handler) { + handler.onItemPressed(view, this) { + handler.onItemAction(view, this) + } + } + open fun refresh() {} + + // Only for toggle + open val showSwitch get() = false + @get:Bindable + open val isChecked get() = false + fun onToggle(view: View, handler: Handler, checked: Boolean) = + set(checked, isChecked, { onPressed(view, handler) }) + + abstract class Value : BaseSettingsItem() { + + /** + * Represents last agreed-upon value by the validation process and the user for current + * child. Be very aware that this shouldn't be **set** unless both sides agreed that _that_ + * is the new value. + * */ + abstract var value: T + protected set + } + + abstract class Toggle : Value() { + + override val showSwitch get() = true + override val isChecked get() = value + + override fun onPressed(view: View, handler: Handler) { + // Make sure the checked state is synced + notifyPropertyChanged(BR.checked) + handler.onItemPressed(view, this) { + value = !value + notifyPropertyChanged(BR.checked) + handler.onItemAction(view, this) + } + } + } + + abstract class Input : Value() { + + @get:Bindable + abstract val inputResult: String? + + override fun onPressed(view: View, handler: Handler) { + handler.onItemPressed(view, this) { + MagiskDialog(view.activity).apply { + setTitle(title.getText(view.resources)) + setView(getView(view.context)) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { + inputResult?.let { result -> + doNotDismiss = false + value = result + handler.onItemAction(view, this@Input) + return@onClick + } + doNotDismiss = true + } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + }.show() + } + } + + abstract fun getView(context: Context): View + } + + abstract class Selector : Value() { + + open val entryRes get() = -1 + open val descriptionRes get() = entryRes + open fun entries(res: Resources) = res.getArrayOrEmpty(entryRes) + open fun descriptions(res: Resources) = res.getArrayOrEmpty(descriptionRes) + + override val description = object : TextHolder() { + override fun getText(resources: Resources): CharSequence { + return descriptions(resources).getOrElse(value) { "" } + } + } + + private fun Resources.getArrayOrEmpty(id: Int): Array = + runCatching { getStringArray(id) }.getOrDefault(emptyArray()) + + override fun onPressed(view: View, handler: Handler) { + handler.onItemPressed(view, this) { + MagiskDialog(view.activity).apply { + setTitle(title.getText(view.resources)) + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + setListItems(entries(view.resources)) { + if (value != it) { + value = it + notifyPropertyChanged(BR.description) + handler.onItemAction(view, this@Selector) + } + } + }.show() + } + } + } + + abstract class Blank : BaseSettingsItem() + + abstract class Section : BaseSettingsItem() { + override val layoutRes = R.layout.item_settings_section + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..0a92a94 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsFragment.kt @@ -0,0 +1,40 @@ +package com.topjohnwu.magisk.ui.settings + +import android.os.Bundle +import android.view.View +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.databinding.FragmentSettingsMd2Binding +import rikka.recyclerview.addEdgeSpacing +import rikka.recyclerview.addItemSpacing +import rikka.recyclerview.fixEdgeEffect +import com.topjohnwu.magisk.core.R as CoreR + +class SettingsFragment : BaseFragment() { + + override val layoutRes = R.layout.fragment_settings_md2 + override val viewModel by viewModel() + override val snackbarView: View get() = binding.snackbarContainer + + override fun onStart() { + super.onStart() + + activity?.title = resources.getString(CoreR.string.settings) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.settingsList.apply { + addEdgeSpacing(bottom = R.dimen.l1) + addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1) + fixEdgeEffect() + } + } + + override fun onResume() { + super.onResume() + viewModel.items.forEach { it.refresh() } + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt new file mode 100644 index 0000000..2bb8864 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt @@ -0,0 +1,333 @@ +package com.topjohnwu.magisk.ui.settings + +import android.content.Context +import android.content.res.Resources +import android.os.Build +import android.view.LayoutInflater +import android.view.View +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.BuildConfig +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.ktx.activity +import com.topjohnwu.magisk.core.tasks.AppMigration +import com.topjohnwu.magisk.core.utils.LocaleSetting +import com.topjohnwu.magisk.core.utils.MediaStoreUtils +import com.topjohnwu.magisk.databinding.DialogSettingsAppNameBinding +import com.topjohnwu.magisk.databinding.DialogSettingsDownloadPathBinding +import com.topjohnwu.magisk.databinding.DialogSettingsUpdateChannelBinding +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.utils.TextHolder +import com.topjohnwu.magisk.utils.asText +import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.superuser.Shell +import com.topjohnwu.magisk.core.R as CoreR + +// --- Customization + +object Customization : BaseSettingsItem.Section() { + override val title = CoreR.string.settings_customization.asText() +} + +object Language : BaseSettingsItem.Selector() { + private val names: Array get() = LocaleSetting.available.names + private val tags: Array get() = LocaleSetting.available.tags + + override var value + get() = tags.indexOf(Config.locale) + set(value) { + Config.locale = tags[value] + } + + override val title = CoreR.string.language.asText() + + override fun entries(res: Resources) = names + override fun descriptions(res: Resources) = names +} + +object LanguageSystem : BaseSettingsItem.Blank() { + override val title = CoreR.string.language.asText() + override val description: TextHolder + get() { + val locale = LocaleSetting.instance.appLocale + return locale?.getDisplayName(locale)?.asText() ?: CoreR.string.system_default.asText() + } +} + +object Theme : BaseSettingsItem.Blank() { + override val icon = R.drawable.ic_paint + override val title = CoreR.string.section_theme.asText() +} + +// --- App + +object AppSettings : BaseSettingsItem.Section() { + override val title = CoreR.string.home_app_title.asText() +} + +object Hide : BaseSettingsItem.Input() { + override val title = CoreR.string.settings_hide_app_title.asText() + override val description = CoreR.string.settings_hide_app_summary.asText() + override var value = "" + + override val inputResult + get() = if (isError) null else result + + @get:Bindable + var result = "Settings" + set(value) = set(value, field, { field = it }, BR.result, BR.error) + + val maxLength + get() = AppMigration.MAX_LABEL_LENGTH + + @get:Bindable + val isError + get() = result.length > maxLength || result.isBlank() + + override fun getView(context: Context) = DialogSettingsAppNameBinding + .inflate(LayoutInflater.from(context)).also { it.data = this }.root +} + +object Restore : BaseSettingsItem.Blank() { + override val title = CoreR.string.settings_restore_app_title.asText() + override val description = CoreR.string.settings_restore_app_summary.asText() + + override fun onPressed(view: View, handler: Handler) { + handler.onItemPressed(view, this) { + MagiskDialog(view.activity).apply { + setTitle(CoreR.string.settings_restore_app_title) + setMessage(CoreR.string.restore_app_confirmation) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { + handler.onItemAction(view, this@Restore) + } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + setCancelable(true) + show() + } + } + } +} + +object AddShortcut : BaseSettingsItem.Blank() { + override val title = CoreR.string.add_shortcut_title.asText() + override val description = CoreR.string.setting_add_shortcut_summary.asText() +} + +object DownloadPath : BaseSettingsItem.Input() { + override var value + get() = Config.downloadDir + set(value) { + Config.downloadDir = value + notifyPropertyChanged(BR.description) + } + + override val title = CoreR.string.settings_download_path_title.asText() + override val description get() = MediaStoreUtils.fullPath(value).asText() + + override var inputResult: String = value + set(value) = set(value, field, { field = it }, BR.inputResult, BR.path) + + @get:Bindable + val path get() = MediaStoreUtils.fullPath(inputResult) + + override fun getView(context: Context) = DialogSettingsDownloadPathBinding + .inflate(LayoutInflater.from(context)).also { it.data = this }.root +} + +object UpdateChannel : BaseSettingsItem.Selector() { + override var value + get() = Config.updateChannel + set(value) { + Config.updateChannel = value + Info.resetUpdate() + } + + override val title = CoreR.string.settings_update_channel_title.asText() + override val entryRes = CoreR.array.update_channel +} + +object UpdateChannelUrl : BaseSettingsItem.Input() { + override val title = CoreR.string.settings_update_custom.asText() + override val description get() = value.asText() + override var value + get() = Config.customChannelUrl + set(value) { + Config.customChannelUrl = value + Info.resetUpdate() + notifyPropertyChanged(BR.description) + } + + override var inputResult: String = value + set(value) = set(value, field, { field = it }, BR.inputResult) + + override fun refresh() { + isEnabled = UpdateChannel.value == Config.Value.CUSTOM_CHANNEL + } + + override fun getView(context: Context) = DialogSettingsUpdateChannelBinding + .inflate(LayoutInflater.from(context)).also { it.data = this }.root +} + +object UpdateChecker : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_check_update_title.asText() + override val description = CoreR.string.settings_check_update_summary.asText() + override var value by Config::checkUpdate +} + +object DoHToggle : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_doh_title.asText() + override val description = CoreR.string.settings_doh_description.asText() + override var value by Config::doh +} + +object SystemlessHosts : BaseSettingsItem.Blank() { + override val title = CoreR.string.settings_hosts_title.asText() + override val description = CoreR.string.settings_hosts_summary.asText() +} + +object RandNameToggle : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_random_name_title.asText() + override val description = CoreR.string.settings_random_name_description.asText() + override var value by Config::randName +} + +// --- Magisk + +object Magisk : BaseSettingsItem.Section() { + override val title = CoreR.string.magisk.asText() +} + +object Zygisk : BaseSettingsItem.Toggle() { + override val title = CoreR.string.zygisk.asText() + override val description get() = + if (mismatch) CoreR.string.reboot_apply_change.asText() + else CoreR.string.settings_zygisk_summary.asText() + override var value + get() = Config.zygisk + set(value) { + Config.zygisk = value + notifyPropertyChanged(BR.description) + } + val mismatch get() = value != Info.isZygiskEnabled +} + +object DenyList : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_denylist_title.asText() + override val description get() = CoreR.string.settings_denylist_summary.asText() + + override var value = Config.denyList + set(value) { + field = value + val cmd = if (value) "enable" else "disable" + Shell.cmd("magisk --denylist $cmd").submit { result -> + if (result.isSuccess) { + Config.denyList = value + } else { + field = !value + notifyPropertyChanged(BR.checked) + } + } + } +} + +object DenyListConfig : BaseSettingsItem.Blank() { + override val title = CoreR.string.settings_denylist_config_title.asText() + override val description = CoreR.string.settings_denylist_config_summary.asText() +} + +// --- Superuser + +object Tapjack : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_su_tapjack_title.asText() + override val description = CoreR.string.settings_su_tapjack_summary.asText() + override var value by Config::suTapjack +} + +object Authentication : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_su_auth_title.asText() + override var description = CoreR.string.settings_su_auth_summary.asText() + override var value by Config::suAuth + + override fun refresh() { + isEnabled = Info.isDeviceSecure + if (!isEnabled) { + description = CoreR.string.settings_su_auth_insecure.asText() + } + } +} + +object Superuser : BaseSettingsItem.Section() { + override val title = CoreR.string.superuser.asText() +} + +object AccessMode : BaseSettingsItem.Selector() { + override val title = CoreR.string.superuser_access.asText() + override val entryRes = CoreR.array.su_access + override var value by Config::rootMode +} + +object MultiuserMode : BaseSettingsItem.Selector() { + override val title = CoreR.string.multiuser_mode.asText() + override val entryRes = CoreR.array.multiuser_mode + override val descriptionRes = CoreR.array.multiuser_summary + override var value by Config::suMultiuserMode + + override fun refresh() { + isEnabled = Const.USER_ID == 0 + } +} + +object MountNamespaceMode : BaseSettingsItem.Selector() { + override val title = CoreR.string.mount_namespace_mode.asText() + override val entryRes = CoreR.array.namespace + override val descriptionRes = CoreR.array.namespace_summary + override var value by Config::suMntNamespaceMode +} + +object AutomaticResponse : BaseSettingsItem.Selector() { + override val title = CoreR.string.auto_response.asText() + override val entryRes = CoreR.array.auto_response + override var value by Config::suAutoResponse +} + +object RequestTimeout : BaseSettingsItem.Selector() { + override val title = CoreR.string.request_timeout.asText() + override val entryRes = CoreR.array.request_timeout + + private val entryValues = listOf(10, 15, 20, 30, 45, 60) + override var value = entryValues.indexOfFirst { it == Config.suDefaultTimeout } + set(value) { + field = value + Config.suDefaultTimeout = entryValues[value] + } +} + +object SUNotification : BaseSettingsItem.Selector() { + override val title = CoreR.string.superuser_notification.asText() + override val entryRes = CoreR.array.su_notification + override var value by Config::suNotification +} + +object Reauthenticate : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_su_reauth_title.asText() + override val description = CoreR.string.settings_su_reauth_summary.asText() + override var value by Config::suReAuth + + override fun refresh() { + isEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O + } +} + +object Restrict : BaseSettingsItem.Toggle() { + override val title = CoreR.string.settings_su_restrict_title.asText() + override val description = CoreR.string.settings_su_restrict_summary.asText() + override var value by Config::suRestrict +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..1a6db30 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsViewModel.kt @@ -0,0 +1,138 @@ +package com.topjohnwu.magisk.ui.settings + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.view.View +import android.widget.Toast +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.BuildConfig +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Const +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.isRunningAsStub +import com.topjohnwu.magisk.core.ktx.activity +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.tasks.AppMigration +import com.topjohnwu.magisk.core.utils.LocaleSetting +import com.topjohnwu.magisk.core.utils.RootUtils +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.events.AddHomeIconEvent +import com.topjohnwu.magisk.events.AuthEvent +import com.topjohnwu.magisk.events.SnackbarEvent +import kotlinx.coroutines.launch + +class SettingsViewModel : BaseViewModel(), BaseSettingsItem.Handler { + + val items = createItems() + val extraBindings = bindExtra { + it.put(BR.handler, this) + } + + private fun createItems(): List { + val context = AppContext + val hidden = context.packageName != BuildConfig.APP_PACKAGE_NAME + + // Customization + val list = mutableListOf( + Customization, + Theme, if (LocaleSetting.useLocaleManager) LanguageSystem else Language + ) + if (isRunningAsStub && ShortcutManagerCompat.isRequestPinShortcutSupported(context)) + list.add(AddShortcut) + + // Manager + list.addAll(listOf( + AppSettings, + UpdateChannel, UpdateChannelUrl, DoHToggle, UpdateChecker, DownloadPath, RandNameToggle + )) + if (Info.env.isActive && Const.USER_ID == 0) { + if (hidden) list.add(Restore) else list.add(Hide) + } + + // Magisk + if (Info.env.isActive) { + list.addAll(listOf( + Magisk, + SystemlessHosts + )) + if (Const.Version.atLeast_24_0()) { + list.addAll(listOf(Zygisk, DenyList, DenyListConfig)) + } + } + + // Superuser + if (Info.showSuperUser) { + list.addAll(listOf( + Superuser, + Tapjack, Authentication, AccessMode, MultiuserMode, MountNamespaceMode, + AutomaticResponse, RequestTimeout, SUNotification + )) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // Re-authenticate is not feasible on 8.0+ + list.add(Reauthenticate) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Can hide overlay windows on 12.0+ + list.remove(Tapjack) + } + if (Const.Version.atLeast_30_1()) { + list.add(Restrict) + } + } + + return list + } + + override fun onItemPressed(view: View, item: BaseSettingsItem, doAction: () -> Unit) { + when (item) { + DownloadPath -> withExternalRW(doAction) + UpdateChecker -> withPostNotificationPermission(doAction) + Authentication -> AuthEvent(doAction).publish() + AutomaticResponse -> if (Config.suAuth) AuthEvent(doAction).publish() else doAction() + else -> doAction() + } + } + + override fun onItemAction(view: View, item: BaseSettingsItem) { + when (item) { + Theme -> SettingsFragmentDirections.actionSettingsFragmentToThemeFragment().navigate() + LanguageSystem -> launchAppLocaleSettings(view.activity) + AddShortcut -> AddHomeIconEvent().publish() + SystemlessHosts -> createHosts() + DenyListConfig -> SettingsFragmentDirections.actionSettingsFragmentToDenyFragment().navigate() + UpdateChannel -> openUrlIfNecessary(view) + is Hide -> viewModelScope.launch { AppMigration.hide(view.activity, item.value) } + Restore -> viewModelScope.launch { AppMigration.restore(view.activity) } + Zygisk -> if (Zygisk.mismatch) SnackbarEvent(R.string.reboot_apply_change).publish() + else -> Unit + } + } + + private fun launchAppLocaleSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS) + intent.data = Uri.fromParts("package", activity.packageName, null) + activity.startActivity(intent) + } + + private fun openUrlIfNecessary(view: View) { + UpdateChannelUrl.refresh() + if (UpdateChannelUrl.isEnabled && UpdateChannelUrl.value.isBlank()) { + UpdateChannelUrl.onPressed(view, this) + } + } + + private fun createHosts() { + viewModelScope.launch { + RootUtils.addSystemlessHosts() + AppContext.toast(R.string.settings_hosts_toast, Toast.LENGTH_SHORT) + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/PolicyRvItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/PolicyRvItem.kt new file mode 100644 index 0000000..ab34aa4 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/PolicyRvItem.kt @@ -0,0 +1,102 @@ +package com.topjohnwu.magisk.ui.superuser + +import android.graphics.drawable.Drawable +import androidx.databinding.Bindable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.model.su.SuPolicy +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ItemWrapper +import com.topjohnwu.magisk.databinding.ObservableRvItem +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.core.R as CoreR + +class PolicyRvItem( + private val viewModel: SuperuserViewModel, + override val item: SuPolicy, + val packageName: String, + private val isSharedUid: Boolean, + val icon: Drawable, + val appName: String +) : ObservableRvItem(), DiffItem, ItemWrapper { + + override val layoutRes = R.layout.item_policy_md2 + + val title get() = if (isSharedUid) "[SharedUID] $appName" else appName + + private inline fun setImpl(new: T, old: T, setter: (T) -> Unit) { + if (old != new) { + setter(new) + } + } + + @get:Bindable + var isExpanded = false + set(value) = set(value, field, { field = it }, BR.expanded) + + val showSlider = Config.suRestrict || item.policy == SuPolicy.RESTRICT + + @get:Bindable + var isEnabled + get() = item.policy >= SuPolicy.ALLOW + set(value) = setImpl(value, isEnabled) { + notifyPropertyChanged(BR.enabled) + viewModel.updatePolicy(this, if (it) SuPolicy.ALLOW else SuPolicy.DENY) + } + + @get:Bindable + var sliderValue + get() = item.policy + set(value) = setImpl(value, sliderValue) { + notifyPropertyChanged(BR.sliderValue) + notifyPropertyChanged(BR.enabled) + viewModel.updatePolicy(this, it) + } + + val sliderValueToPolicyString: (Float) -> Int = { value -> + when (value.toInt()) { + 1 -> CoreR.string.deny + 2 -> CoreR.string.restrict + 3 -> CoreR.string.grant + else -> CoreR.string.deny + } + } + + @get:Bindable + var shouldNotify + get() = item.notification + private set(value) = setImpl(value, shouldNotify) { + item.notification = it + viewModel.updateNotify(this) + } + + @get:Bindable + var shouldLog + get() = item.logging + private set(value) = setImpl(value, shouldLog) { + item.logging = it + viewModel.updateLogging(this) + } + + fun toggleExpand() { + isExpanded = !isExpanded + } + + fun toggleNotify() { + shouldNotify = !shouldNotify + } + + fun toggleLog() { + shouldLog = !shouldLog + } + + fun revoke() { + viewModel.deletePressed(this) + } + + override fun itemSameAs(other: PolicyRvItem) = packageName == other.packageName + + override fun contentSameAs(other: PolicyRvItem) = item.policy == other.item.policy + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserFragment.kt new file mode 100644 index 0000000..6ba346d --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserFragment.kt @@ -0,0 +1,36 @@ +package com.topjohnwu.magisk.ui.superuser + +import android.os.Bundle +import android.view.View +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.databinding.FragmentSuperuserMd2Binding +import rikka.recyclerview.addEdgeSpacing +import rikka.recyclerview.addItemSpacing +import rikka.recyclerview.fixEdgeEffect +import com.topjohnwu.magisk.core.R as CoreR + +class SuperuserFragment : BaseFragment() { + + override val layoutRes = R.layout.fragment_superuser_md2 + override val viewModel by viewModel() + + override fun onStart() { + super.onStart() + activity?.title = resources.getString(CoreR.string.superuser) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.superuserList.apply { + addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1) + addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1) + fixEdgeEffect() + } + } + + override fun onPreBind(binding: FragmentSuperuserMd2Binding) {} + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt new file mode 100644 index 0000000..3922c92 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt @@ -0,0 +1,180 @@ +package com.topjohnwu.magisk.ui.superuser + +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES +import android.os.Process +import androidx.databinding.Bindable +import androidx.databinding.ObservableArrayList +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.arch.AsyncLoadViewModel +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.Info +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.data.magiskdb.PolicyDao +import com.topjohnwu.magisk.core.ktx.getLabel +import com.topjohnwu.magisk.core.model.su.SuPolicy +import com.topjohnwu.magisk.databinding.MergeObservableList +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.databinding.diffList +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.dialog.SuperuserRevokeDialog +import com.topjohnwu.magisk.events.AuthEvent +import com.topjohnwu.magisk.events.SnackbarEvent +import com.topjohnwu.magisk.utils.asText +import com.topjohnwu.magisk.view.TextItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale + +class SuperuserViewModel( + private val db: PolicyDao +) : AsyncLoadViewModel() { + + private val itemNoData = TextItem(R.string.superuser_policy_none) + + private val itemsHelpers = ObservableArrayList() + private val itemsPolicies = diffList() + + val items = MergeObservableList() + .insertList(itemsHelpers) + .insertList(itemsPolicies) + val extraBindings = bindExtra { + it.put(BR.listener, this) + } + + @get:Bindable + var loading = true + private set(value) = set(value, field, { field = it }, BR.loading) + + @SuppressLint("InlinedApi") + override suspend fun doLoadWork() { + if (!Info.showSuperUser) { + loading = false + return + } + loading = true + withContext(Dispatchers.IO) { + db.deleteOutdated() + db.delete(AppContext.applicationInfo.uid) + val policies = ArrayList() + val pm = AppContext.packageManager + for (policy in db.fetchAll()) { + val pkgs = + if (policy.uid == Process.SYSTEM_UID) arrayOf("android") + else pm.getPackagesForUid(policy.uid) + if (pkgs == null) { + db.delete(policy.uid) + continue + } + val map = pkgs.mapNotNull { pkg -> + try { + val info = pm.getPackageInfo(pkg, MATCH_UNINSTALLED_PACKAGES) + PolicyRvItem( + this@SuperuserViewModel, policy, + info.packageName, + info.sharedUserId != null, + info.applicationInfo?.loadIcon(pm) ?: pm.defaultActivityIcon, + info.applicationInfo?.getLabel(pm) ?: info.packageName + ) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } + if (map.isEmpty()) { + db.delete(policy.uid) + continue + } + policies.addAll(map) + } + policies.sortWith(compareBy( + { it.appName.lowercase(Locale.ROOT) }, + { it.packageName } + )) + itemsPolicies.update(policies) + } + if (itemsPolicies.isNotEmpty()) + itemsHelpers.clear() + else if (itemsHelpers.isEmpty()) + itemsHelpers.add(itemNoData) + loading = false + } + + // --- + + fun deletePressed(item: PolicyRvItem) { + fun updateState() = viewModelScope.launch { + db.delete(item.item.uid) + val list = ArrayList(itemsPolicies) + list.removeAll { it.item.uid == item.item.uid } + itemsPolicies.update(list) + if (list.isEmpty() && itemsHelpers.isEmpty()) { + itemsHelpers.add(itemNoData) + } + } + + if (Config.suAuth) { + AuthEvent { updateState() }.publish() + } else { + SuperuserRevokeDialog(item.title) { updateState() }.show() + } + } + + fun updateNotify(item: PolicyRvItem) { + viewModelScope.launch { + db.update(item.item) + val res = when { + item.item.notification -> R.string.su_snack_notif_on + else -> R.string.su_snack_notif_off + } + itemsPolicies.forEach { + if (it.item.uid == item.item.uid) { + it.notifyPropertyChanged(BR.shouldNotify) + } + } + SnackbarEvent(res.asText(item.appName)).publish() + } + } + + fun updateLogging(item: PolicyRvItem) { + viewModelScope.launch { + db.update(item.item) + val res = when { + item.item.logging -> R.string.su_snack_log_on + else -> R.string.su_snack_log_off + } + itemsPolicies.forEach { + if (it.item.uid == item.item.uid) { + it.notifyPropertyChanged(BR.shouldLog) + } + } + SnackbarEvent(res.asText(item.appName)).publish() + } + } + + fun updatePolicy(item: PolicyRvItem, policy: Int) { + val items = itemsPolicies.filter { it.item.uid == item.item.uid } + fun updateState() { + viewModelScope.launch { + val res = if (policy >= SuPolicy.ALLOW) R.string.su_snack_grant else R.string.su_snack_deny + item.item.policy = policy + db.update(item.item) + items.forEach { + it.notifyPropertyChanged(BR.enabled) + it.notifyPropertyChanged(BR.sliderValue) + } + SnackbarEvent(res.asText(item.appName)).publish() + } + } + + if (Config.suAuth) { + AuthEvent { updateState() }.publish() + } else { + updateState() + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestActivity.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestActivity.kt new file mode 100644 index 0000000..9e04c0f --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestActivity.kt @@ -0,0 +1,69 @@ +package com.topjohnwu.magisk.ui.surequest + +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.res.Resources +import android.os.Build +import android.os.Bundle +import android.view.Window +import android.view.WindowManager +import androidx.lifecycle.lifecycleScope +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.UIActivity +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.core.base.UntrackedActivity +import com.topjohnwu.magisk.core.su.SuCallbackHandler +import com.topjohnwu.magisk.core.su.SuCallbackHandler.REQUEST +import com.topjohnwu.magisk.databinding.ActivityRequestBinding +import com.topjohnwu.magisk.ui.theme.Theme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +open class SuRequestActivity : UIActivity(), UntrackedActivity { + + override val layoutRes: Int = R.layout.activity_request + override val viewModel: SuRequestViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + supportRequestWindowFeature(Window.FEATURE_NO_TITLE) + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.addFlags(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + window.setHideOverlayWindows(true) + } + setTheme(Theme.selected.themeRes) + super.onCreate(savedInstanceState) + + if (intent.action == Intent.ACTION_VIEW) { + val action = intent.getStringExtra("action") + if (action == REQUEST) { + viewModel.handleRequest(intent) + } else { + lifecycleScope.launch { + withContext(Dispatchers.IO) { + SuCallbackHandler.run(this@SuRequestActivity, action, intent.extras) + } + finish() + } + } + } else { + finish() + } + } + + override fun getTheme(): Resources.Theme { + val theme = super.getTheme() + theme.applyStyle(R.style.Foundation_Floating, true) + return theme + } + + override fun onBackPressed() { + viewModel.denyPressed() + } + + override fun finish() { + super.finishAndRemoveTask() + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt new file mode 100644 index 0000000..4d1a830 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt @@ -0,0 +1,199 @@ +package com.topjohnwu.magisk.ui.surequest + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.os.CountDownTimer +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeProvider +import android.widget.Toast +import androidx.databinding.Bindable +import androidx.lifecycle.viewModelScope +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.core.AppContext +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.core.R +import com.topjohnwu.magisk.core.data.magiskdb.PolicyDao +import com.topjohnwu.magisk.core.ktx.getLabel +import com.topjohnwu.magisk.core.ktx.toast +import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.ALLOW +import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.DENY +import com.topjohnwu.magisk.core.su.SuRequestHandler +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.events.AuthEvent +import com.topjohnwu.magisk.events.DieEvent +import com.topjohnwu.magisk.events.ShowUIEvent +import com.topjohnwu.magisk.utils.TextHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit.SECONDS + +class SuRequestViewModel( + policyDB: PolicyDao, + private val timeoutPrefs: SharedPreferences +) : BaseViewModel() { + + lateinit var icon: Drawable + lateinit var title: String + lateinit var packageName: String + + @get:Bindable + val denyText = DenyText() + + @get:Bindable + var selectedItemPosition = 0 + set(value) = set(value, field, { field = it }, BR.selectedItemPosition) + + @get:Bindable + var grantEnabled = false + set(value) = set(value, field, { field = it }, BR.grantEnabled) + + @SuppressLint("ClickableViewAccessibility") + val grantTouchListener = View.OnTouchListener { _: View, event: MotionEvent -> + // Filter obscured touches by consuming them. + if (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0 + || event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0) { + if (event.action == MotionEvent.ACTION_UP) { + AppContext.toast(R.string.touch_filtered_warning, Toast.LENGTH_SHORT) + } + return@OnTouchListener Config.suTapjack + } + false + } + + private val handler = SuRequestHandler(AppContext.packageManager, policyDB) + private val millis = SECONDS.toMillis(Config.suDefaultTimeout.toLong()) + private var timer = SuTimer(millis, 1000) + private var initialized = false + + fun grantPressed() { + cancelTimer() + if (Config.suAuth) { + AuthEvent { respond(ALLOW) }.publish() + } else { + respond(ALLOW) + } + } + + fun denyPressed() { + respond(DENY) + } + + fun spinnerTouched(): Boolean { + cancelTimer() + return false + } + + fun handleRequest(intent: Intent) { + viewModelScope.launch(Dispatchers.Default) { + if (handler.start(intent)) + showDialog() + else + DieEvent().publish() + } + } + + private fun showDialog() { + val pm = handler.pm + val info = handler.pkgInfo + val app = info.applicationInfo + + if (app == null) { + // The request is not coming from an app process, and the UID is a + // shared UID. We have no way to know where this request comes from. + icon = pm.defaultActivityIcon + title = "[SharedUID] ${info.sharedUserId}" + packageName = info.sharedUserId.toString() + } else { + val prefix = if (info.sharedUserId == null) "" else "[SharedUID] " + icon = app.loadIcon(pm) + title = "$prefix${app.getLabel(pm)}" + packageName = info.packageName + } + + selectedItemPosition = timeoutPrefs.getInt(packageName, 0) + + // Set timer + timer.start() + + // Actually show the UI + ShowUIEvent(if (Config.suTapjack) EmptyAccessibilityDelegate else null).publish() + initialized = true + } + + private fun respond(action: Int) { + if (!initialized) { + // ignore the response until showDialog done + return + } + + timer.cancel() + + val pos = selectedItemPosition + timeoutPrefs.edit().putInt(packageName, pos).apply() + + viewModelScope.launch { + handler.respond(action, Config.Value.TIMEOUT_LIST[pos]) + // Kill activity after response + DieEvent().publish() + } + } + + private fun cancelTimer() { + timer.cancel() + denyText.seconds = 0 + } + + private inner class SuTimer( + private val millis: Long, + interval: Long + ) : CountDownTimer(millis, interval) { + + override fun onTick(remains: Long) { + if (!grantEnabled && remains <= millis - 1000) { + grantEnabled = true + } + denyText.seconds = (remains / 1000).toInt() + 1 + } + + override fun onFinish() { + denyText.seconds = 0 + respond(DENY) + } + + } + + inner class DenyText : TextHolder() { + var seconds = 0 + set(value) = set(value, field, { field = it }, BR.denyText) + + override fun getText(resources: Resources): CharSequence { + return if (seconds != 0) + "${resources.getString(R.string.deny)} ($seconds)" + else + resources.getString(R.string.deny) + } + } + + // Invisible for accessibility services + object EmptyAccessibilityDelegate : View.AccessibilityDelegate() { + override fun sendAccessibilityEvent(host: View, eventType: Int) {} + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?) = true + override fun sendAccessibilityEventUnchecked(host: View, event: AccessibilityEvent) {} + override fun dispatchPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) = true + override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {} + override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {} + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {} + override fun addExtraDataToAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo, extraDataKey: String, arguments: Bundle?) {} + override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean = false + override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProvider? = null + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/Theme.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/Theme.kt new file mode 100644 index 0000000..4fb9f72 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/Theme.kt @@ -0,0 +1,50 @@ +package com.topjohnwu.magisk.ui.theme + +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.core.Config + +enum class Theme( + val themeName: String, + val themeRes: Int +) { + + Piplup( + themeName = "Piplup", + themeRes = R.style.ThemeFoundationMD2_Piplup + ), + PiplupAmoled( + themeName = "AMOLED", + themeRes = R.style.ThemeFoundationMD2_Amoled + ), + Rayquaza( + themeName = "Rayquaza", + themeRes = R.style.ThemeFoundationMD2_Rayquaza + ), + Zapdos( + themeName = "Zapdos", + themeRes = R.style.ThemeFoundationMD2_Zapdos + ), + Charmeleon( + themeName = "Charmeleon", + themeRes = R.style.ThemeFoundationMD2_Charmeleon + ), + Mew( + themeName = "Mew", + themeRes = R.style.ThemeFoundationMD2_Mew + ), + Salamence( + themeName = "Salamence", + themeRes = R.style.ThemeFoundationMD2_Salamence + ), + Fraxure( + themeName = "Fraxure (Legacy)", + themeRes = R.style.ThemeFoundationMD2_Fraxure + ); + + val isSelected get() = Config.themeOrdinal == ordinal + + companion object { + val selected get() = values().getOrNull(Config.themeOrdinal) ?: Piplup + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/ThemeFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/ThemeFragment.kt new file mode 100644 index 0000000..f66aad6 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/ThemeFragment.kt @@ -0,0 +1,68 @@ +package com.topjohnwu.magisk.ui.theme + +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.BaseFragment +import com.topjohnwu.magisk.arch.viewModel +import com.topjohnwu.magisk.databinding.FragmentThemeMd2Binding +import com.topjohnwu.magisk.databinding.ItemThemeBindingImpl +import com.topjohnwu.magisk.core.R as CoreR + +class ThemeFragment : BaseFragment() { + + override val layoutRes = R.layout.fragment_theme_md2 + override val viewModel by viewModel() + + private fun Array.paired(): List> { + val iterator = iterator() + if (!iterator.hasNext()) return emptyList() + val result = mutableListOf>() + while (iterator.hasNext()) { + val a = iterator.next() + val b = if (iterator.hasNext()) iterator.next() else null + result.add(a to b) + } + return result + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + + for ((a, b) in Theme.values().paired()) { + val c = inflater.inflate(R.layout.item_theme_container, null, false) + val left = c.findViewById(R.id.left) + val right = c.findViewById(R.id.right) + + for ((theme, view) in listOf(a to left, b to right)) { + theme ?: continue + val themed = ContextThemeWrapper(activity, theme.themeRes) + ItemThemeBindingImpl.inflate(LayoutInflater.from(themed), view, true).also { + it.setVariable(BR.viewModel, viewModel) + it.setVariable(BR.theme, theme) + it.lifecycleOwner = viewLifecycleOwner + } + } + + binding.themeContainer.addView(c) + } + + return binding.root + } + + override fun onStart() { + super.onStart() + + activity?.title = getString(CoreR.string.section_theme) + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/ThemeViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/ThemeViewModel.kt new file mode 100644 index 0000000..3860994 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/theme/ThemeViewModel.kt @@ -0,0 +1,23 @@ +package com.topjohnwu.magisk.ui.theme + +import com.topjohnwu.magisk.arch.BaseViewModel +import com.topjohnwu.magisk.core.Config +import com.topjohnwu.magisk.dialog.DarkThemeDialog +import com.topjohnwu.magisk.events.RecreateEvent +import com.topjohnwu.magisk.view.TappableHeadlineItem + +class ThemeViewModel : BaseViewModel(), TappableHeadlineItem.Listener { + + val themeHeadline = TappableHeadlineItem.ThemeMode + + override fun onItemPressed(item: TappableHeadlineItem) = when (item) { + is TappableHeadlineItem.ThemeMode -> DarkThemeDialog().show() + } + + fun saveTheme(theme: Theme) { + if (!theme.isSelected) { + Config.themeOrdinal = theme.ordinal + RecreateEvent().publish() + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/utils/AccessibilityUtils.kt b/app/apk/src/main/java/com/topjohnwu/magisk/utils/AccessibilityUtils.kt new file mode 100644 index 0000000..4ac8526 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/utils/AccessibilityUtils.kt @@ -0,0 +1,14 @@ +package com.topjohnwu.magisk.utils + +import android.content.ContentResolver +import android.provider.Settings + +class AccessibilityUtils { + companion object { + fun isAnimationEnabled(cr: ContentResolver): Boolean { + return !(Settings.Global.getFloat(cr, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f) == 0.0f + && Settings.Global.getFloat(cr, Settings.Global.TRANSITION_ANIMATION_SCALE, 1.0f) == 0.0f + && Settings.Global.getFloat(cr, Settings.Global.WINDOW_ANIMATION_SCALE, 1.0f) == 0.0f) + } + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/utils/MotionRevealHelper.kt b/app/apk/src/main/java/com/topjohnwu/magisk/utils/MotionRevealHelper.kt new file mode 100644 index 0000000..23eb728 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/utils/MotionRevealHelper.kt @@ -0,0 +1,86 @@ +package com.topjohnwu.magisk.utils + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.view.View +import androidx.core.animation.addListener +import androidx.core.text.layoutDirection +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.marginBottom +import androidx.core.view.marginEnd +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import com.google.android.material.circularreveal.CircularRevealCompat +import com.google.android.material.circularreveal.CircularRevealWidget +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.topjohnwu.magisk.core.utils.LocaleSetting +import kotlin.math.hypot + +object MotionRevealHelper { + + fun withViews( + revealable: CV, + fab: FloatingActionButton, + expanded: Boolean + ) where CV : CircularRevealWidget, CV : View { + revealable.revealInfo = revealable.createRevealInfo(!expanded) + + val revealInfo = revealable.createRevealInfo(expanded) + val revealAnim = revealable.createRevealAnim(revealInfo) + val moveAnim = fab.createMoveAnim(revealInfo) + + AnimatorSet().also { + if (expanded) { + it.play(revealAnim).after(moveAnim) + } else { + it.play(moveAnim).after(revealAnim) + } + }.start() + } + + private fun CV.createRevealAnim( + revealInfo: CircularRevealWidget.RevealInfo + ): Animator where CV : CircularRevealWidget, CV : View = + CircularRevealCompat.createCircularReveal( + this, + revealInfo.centerX, + revealInfo.centerY, + revealInfo.radius + ).apply { + addListener(onStart = { + isVisible = true + }, onEnd = { + if (revealInfo.radius == 0f) { + isInvisible = true + } + }) + } + + private fun FloatingActionButton.createMoveAnim( + revealInfo: CircularRevealWidget.RevealInfo + ): Animator = AnimatorSet().also { + it.interpolator = FastOutSlowInInterpolator() + it.addListener(onStart = { show() }, onEnd = { if (revealInfo.radius != 0f) hide() }) + + val rtlMod = + if (LocaleSetting.instance.currentLocale.layoutDirection == View.LAYOUT_DIRECTION_RTL) + 1f else -1f + val maxX = revealInfo.centerX - marginEnd - measuredWidth / 2f + val targetX = if (revealInfo.radius == 0f) 0f else maxX * rtlMod + val moveX = ObjectAnimator.ofFloat(this, View.TRANSLATION_X, targetX) + + val maxY = revealInfo.centerY - marginBottom - measuredHeight / 2f + val targetY = if (revealInfo.radius == 0f) 0f else -maxY + val moveY = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, targetY) + + it.playTogether(moveX, moveY) + } + + private fun View.createRevealInfo(expanded: Boolean): CircularRevealWidget.RevealInfo { + val cX = measuredWidth / 2f + val cY = measuredHeight / 2f - paddingBottom + return CircularRevealWidget.RevealInfo(cX, cY, if (expanded) hypot(cX, cY) else 0f) + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/utils/TextHolder.kt b/app/apk/src/main/java/com/topjohnwu/magisk/utils/TextHolder.kt new file mode 100644 index 0000000..e1c2141 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/utils/TextHolder.kt @@ -0,0 +1,46 @@ +package com.topjohnwu.magisk.utils + +import android.content.res.Resources + +abstract class TextHolder { + + open val isEmpty: Boolean get() = false + abstract fun getText(resources: Resources): CharSequence + + // --- + + class String( + private val value: CharSequence + ) : TextHolder() { + override val isEmpty get() = value.isEmpty() + override fun getText(resources: Resources) = value + } + + open class Resource( + protected val value: Int + ) : TextHolder() { + override val isEmpty get() = value == 0 + override fun getText(resources: Resources) = resources.getString(value) + } + + class ResourceArgs( + value: Int, + private vararg val params: Any + ) : Resource(value) { + override fun getText(resources: Resources): kotlin.String { + // Replace TextHolder with strings + val args = params.map { if (it is TextHolder) it.getText(resources) else it } + return resources.getString(value, *args.toTypedArray()) + } + } + + // --- + + companion object { + val EMPTY = String("") + } +} + +fun Int.asText(): TextHolder = TextHolder.Resource(this) +fun Int.asText(vararg params: Any): TextHolder = TextHolder.ResourceArgs(this, *params) +fun CharSequence.asText(): TextHolder = TextHolder.String(this) diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/view/MagiskDialog.kt b/app/apk/src/main/java/com/topjohnwu/magisk/view/MagiskDialog.kt new file mode 100644 index 0000000..df1e236 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/view/MagiskDialog.kt @@ -0,0 +1,232 @@ +package com.topjohnwu.magisk.view + +import android.app.Activity +import android.content.DialogInterface +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatDialog +import androidx.appcompat.content.res.AppCompatResources +import androidx.databinding.Bindable +import androidx.databinding.PropertyChangeRegistry +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors +import com.google.android.material.shape.MaterialShapeDrawable +import com.topjohnwu.magisk.BR +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.arch.UIActivity +import com.topjohnwu.magisk.databinding.DialogMagiskBaseBinding +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ItemWrapper +import com.topjohnwu.magisk.databinding.ObservableHost +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.databinding.bindExtra +import com.topjohnwu.magisk.databinding.set +import com.topjohnwu.magisk.databinding.setAdapter +import com.topjohnwu.magisk.view.MagiskDialog.DialogClickListener + +typealias DialogButtonClickListener = (DialogInterface) -> Unit + +class MagiskDialog( + context: Activity, theme: Int = 0 +) : AppCompatDialog(context, theme) { + + private val binding: DialogMagiskBaseBinding = + DialogMagiskBaseBinding.inflate(LayoutInflater.from(context)) + private val data = Data() + + val activity: UIActivity<*> get() = ownerActivity as UIActivity<*> + + init { + binding.setVariable(BR.data, data) + setCancelable(true) + setOwnerActivity(context) + } + + inner class Data : ObservableHost { + override var callbacks: PropertyChangeRegistry? = null + + @get:Bindable + var icon: Drawable? = null + set(value) = set(value, field, { field = it }, BR.icon) + + @get:Bindable + var title: CharSequence = "" + set(value) = set(value, field, { field = it }, BR.title) + + @get:Bindable + var message: CharSequence = "" + set(value) = set(value, field, { field = it }, BR.message) + + val buttonPositive = ButtonViewModel() + val buttonNeutral = ButtonViewModel() + val buttonNegative = ButtonViewModel() + } + + enum class ButtonType { + POSITIVE, NEUTRAL, NEGATIVE + } + + interface Button { + var icon: Int + var text: Any + var isEnabled: Boolean + var doNotDismiss: Boolean + + fun onClick(listener: DialogButtonClickListener) + } + + inner class ButtonViewModel : Button, ObservableHost { + override var callbacks: PropertyChangeRegistry? = null + + @get:Bindable + override var icon = 0 + set(value) = set(value, field, { field = it }, BR.icon, BR.gone) + + @get:Bindable + var message: String = "" + set(value) = set(value, field, { field = it }, BR.message, BR.gone) + + override var text: Any + get() = message + set(value) { + message = when (value) { + is Int -> context.getText(value) + else -> value + }.toString() + } + + @get:Bindable + val gone get() = icon == 0 && message.isEmpty() + + @get:Bindable + override var isEnabled = true + set(value) = set(value, field, { field = it }, BR.enabled) + + override var doNotDismiss = false + + private var onClickAction: DialogButtonClickListener = {} + + override fun onClick(listener: DialogButtonClickListener) { + onClickAction = listener + } + + fun clicked() { + onClickAction(this@MagiskDialog) + if (!doNotDismiss) { + dismiss() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + super.setContentView(binding.root) + + val default = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, javaClass.canonicalName) + val surfaceColor = MaterialColors.getColor(context, R.attr.colorSurfaceSurfaceVariant, default) + val materialShapeDrawable = MaterialShapeDrawable(context, null, androidx.appcompat.R.attr.alertDialogStyle, com.google.android.material.R.style.MaterialAlertDialog_MaterialComponents) + materialShapeDrawable.initializeElevationOverlay(context) + materialShapeDrawable.fillColor = ColorStateList.valueOf(surfaceColor) + materialShapeDrawable.elevation = context.resources.getDimension(R.dimen.margin_generic) + materialShapeDrawable.setCornerSize(context.resources.getDimension(R.dimen.l_50)) + + val inset = context.resources.getDimensionPixelSize(com.google.android.material.R.dimen.appcompat_dialog_background_inset) + window?.apply { + setBackgroundDrawable(InsetDrawable(materialShapeDrawable, inset, inset, inset, inset)) + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun setTitle(@StringRes titleId: Int) { data.title = context.getString(titleId) } + + override fun setTitle(title: CharSequence?) { data.title = title ?: "" } + + fun setMessage(@StringRes msgId: Int, vararg args: Any) { + data.message = context.getString(msgId, *args) + } + + fun setMessage(message: CharSequence) { data.message = message } + + fun setIcon(@DrawableRes drawableRes: Int) { + data.icon = AppCompatResources.getDrawable(context, drawableRes) + } + + fun setIcon(drawable: Drawable) { data.icon = drawable } + + fun setButton(buttonType: ButtonType, builder: Button.() -> Unit) { + val button = when (buttonType) { + ButtonType.POSITIVE -> data.buttonPositive + ButtonType.NEUTRAL -> data.buttonNeutral + ButtonType.NEGATIVE -> data.buttonNegative + } + button.apply(builder) + } + + class DialogItem( + override val item: CharSequence, + val position: Int + ) : RvItem(), DiffItem, ItemWrapper { + override val layoutRes = R.layout.item_list_single_line + } + + fun interface DialogClickListener { + fun onClick(position: Int) + } + + fun setListItems( + list: Array, + listener: DialogClickListener + ) = setView( + RecyclerView(context).also { + it.isNestedScrollingEnabled = false + it.layoutManager = LinearLayoutManager(context) + + val items = list.mapIndexed { i, cs -> DialogItem(cs, i) } + val extraBindings = bindExtra { sa -> + sa.put(BR.listener, DialogClickListener { pos -> + listener.onClick(pos) + dismiss() + }) + } + it.setAdapter(items, extraBindings) + } + ) + + fun setView(view: View) { + binding.dialogBaseContainer.removeAllViews() + binding.dialogBaseContainer.addView( + view, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + fun resetButtons() { + ButtonType.values().forEach { + setButton(it) { + text = "" + icon = 0 + isEnabled = true + doNotDismiss = false + onClick {} + } + } + } + + // Prevent calling setContentView + + @Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR) + override fun setContentView(layoutResID: Int) {} + @Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR) + override fun setContentView(view: View) {} + @Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR) + override fun setContentView(view: View, params: ViewGroup.LayoutParams?) {} +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/view/TappableHeadlineItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/view/TappableHeadlineItem.kt new file mode 100644 index 0000000..4fbd6d9 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/view/TappableHeadlineItem.kt @@ -0,0 +1,30 @@ +package com.topjohnwu.magisk.view + +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.RvItem +import com.topjohnwu.magisk.core.R as CoreR + +sealed class TappableHeadlineItem : RvItem(), DiffItem { + + abstract val title: Int + abstract val icon: Int + + override val layoutRes = R.layout.item_tappable_headline + + // --- listener + + interface Listener { + + fun onItemPressed(item: TappableHeadlineItem) + + } + + // --- objects + + object ThemeMode : TappableHeadlineItem() { + override val title = CoreR.string.settings_dark_mode_title + override val icon = R.drawable.ic_day_night + } + +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/view/TextItem.kt b/app/apk/src/main/java/com/topjohnwu/magisk/view/TextItem.kt new file mode 100644 index 0000000..6056e46 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/view/TextItem.kt @@ -0,0 +1,10 @@ +package com.topjohnwu.magisk.view + +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.databinding.DiffItem +import com.topjohnwu.magisk.databinding.ItemWrapper +import com.topjohnwu.magisk.databinding.RvItem + +class TextItem(override val item: Int) : RvItem(), DiffItem, ItemWrapper { + override val layoutRes = R.layout.item_text +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/widget/ConcealableBottomNavigationView.java b/app/apk/src/main/java/com/topjohnwu/magisk/widget/ConcealableBottomNavigationView.java new file mode 100644 index 0000000..93c4c98 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/widget/ConcealableBottomNavigationView.java @@ -0,0 +1,135 @@ +package com.topjohnwu.magisk.widget; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.animation.StateListAnimator; +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.customview.view.AbsSavedState; +import androidx.interpolator.view.animation.FastOutLinearInInterpolator; + +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.topjohnwu.magisk.R; + +public class ConcealableBottomNavigationView extends BottomNavigationView { + + private static final int[] STATE_SET = { + R.attr.state_hidden + }; + + private boolean isHidden; + public ConcealableBottomNavigationView(@NonNull Context context) { + this(context, null); + } + + public ConcealableBottomNavigationView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, com.google.android.material.R.attr.bottomNavigationStyle); + } + + public ConcealableBottomNavigationView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, com.google.android.material.R.style.Widget_Design_BottomNavigationView); + } + + public ConcealableBottomNavigationView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + private void recreateAnimator(int height) { + Animator toHidden = ObjectAnimator.ofFloat(this, "translationY", height); + toHidden.setDuration(175); + toHidden.setInterpolator(new FastOutLinearInInterpolator()); + Animator toUnhidden = ObjectAnimator.ofFloat(this, "translationY", 0); + toUnhidden.setDuration(225); + toUnhidden.setInterpolator(new FastOutLinearInInterpolator()); + + StateListAnimator animator = new StateListAnimator(); + + animator.addState(STATE_SET, toHidden); + animator.addState(new int[]{}, toUnhidden); + + setStateListAnimator(animator); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + recreateAnimator(getMeasuredHeight()); + } + + @Override + protected int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isHidden()) { + mergeDrawableStates(drawableState, STATE_SET); + } + return drawableState; + } + + public boolean isHidden() { + return isHidden; + } + + public void setHidden(boolean raised) { + if (isHidden != raised) { + isHidden = raised; + refreshDrawableState(); + } + } + + @NonNull + @Override + protected Parcelable onSaveInstanceState() { + SavedState state = new SavedState(super.onSaveInstanceState()); + state.isHidden = isHidden(); + return state; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + final SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + + if (ss.isHidden) { + setHidden(isHidden); + } + } + + static class SavedState extends AbsSavedState { + + public boolean isHidden; + + public SavedState(Parcel source) { + super(source, ConcealableBottomNavigationView.class.getClassLoader()); + isHidden = source.readByte() != 0; + } + + public SavedState(Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeByte(isHidden ? (byte) 1 : (byte) 0); + } + + public static final Creator CREATOR = new Creator<>() { + + @Override + public SavedState createFromParcel(Parcel source) { + return new SavedState(source); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/app/apk/src/main/res/anim/fragment_enter.xml b/app/apk/src/main/res/anim/fragment_enter.xml new file mode 100644 index 0000000..affbb54 --- /dev/null +++ b/app/apk/src/main/res/anim/fragment_enter.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/anim/fragment_enter_pop.xml b/app/apk/src/main/res/anim/fragment_enter_pop.xml new file mode 100644 index 0000000..6a1d9f3 --- /dev/null +++ b/app/apk/src/main/res/anim/fragment_enter_pop.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/anim/fragment_exit.xml b/app/apk/src/main/res/anim/fragment_exit.xml new file mode 100644 index 0000000..59ca284 --- /dev/null +++ b/app/apk/src/main/res/anim/fragment_exit.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/anim/fragment_exit_pop.xml b/app/apk/src/main/res/anim/fragment_exit_pop.xml new file mode 100644 index 0000000..c39aaf6 --- /dev/null +++ b/app/apk/src/main/res/anim/fragment_exit_pop.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_card_background_color_selector.xml b/app/apk/src/main/res/color/color_card_background_color_selector.xml new file mode 100644 index 0000000..10805d3 --- /dev/null +++ b/app/apk/src/main/res/color/color_card_background_color_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/apk/src/main/res/color/color_error_transient.xml b/app/apk/src/main/res/color/color_error_transient.xml new file mode 100644 index 0000000..22fd949 --- /dev/null +++ b/app/apk/src/main/res/color/color_error_transient.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_menu_tint.xml b/app/apk/src/main/res/color/color_menu_tint.xml new file mode 100644 index 0000000..030b0e5 --- /dev/null +++ b/app/apk/src/main/res/color/color_menu_tint.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_on_primary_transient.xml b/app/apk/src/main/res/color/color_on_primary_transient.xml new file mode 100644 index 0000000..d82ebd1 --- /dev/null +++ b/app/apk/src/main/res/color/color_on_primary_transient.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_primary_error_transient.xml b/app/apk/src/main/res/color/color_primary_error_transient.xml new file mode 100644 index 0000000..b0bcb82 --- /dev/null +++ b/app/apk/src/main/res/color/color_primary_error_transient.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_primary_transient.xml b/app/apk/src/main/res/color/color_primary_transient.xml new file mode 100644 index 0000000..b792af8 --- /dev/null +++ b/app/apk/src/main/res/color/color_primary_transient.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_state_primary_transient.xml b/app/apk/src/main/res/color/color_state_primary_transient.xml new file mode 100644 index 0000000..f979fd3 --- /dev/null +++ b/app/apk/src/main/res/color/color_state_primary_transient.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/color/color_text_transient.xml b/app/apk/src/main/res/color/color_text_transient.xml new file mode 100644 index 0000000..b394648 --- /dev/null +++ b/app/apk/src/main/res/color/color_text_transient.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/avd_bug_from_filled.xml b/app/apk/src/main/res/drawable/avd_bug_from_filled.xml new file mode 100644 index 0000000..b56ed84 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_bug_from_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_bug_to_filled.xml b/app/apk/src/main/res/drawable/avd_bug_to_filled.xml new file mode 100644 index 0000000..a0022e3 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_bug_to_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_circle_check_from_filled.xml b/app/apk/src/main/res/drawable/avd_circle_check_from_filled.xml new file mode 100644 index 0000000..c39d8e0 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_circle_check_from_filled.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_circle_check_to_filled.xml b/app/apk/src/main/res/drawable/avd_circle_check_to_filled.xml new file mode 100644 index 0000000..b021ca2 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_circle_check_to_filled.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_home_from_filled.xml b/app/apk/src/main/res/drawable/avd_home_from_filled.xml new file mode 100644 index 0000000..d433065 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_home_from_filled.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_home_to_filled.xml b/app/apk/src/main/res/drawable/avd_home_to_filled.xml new file mode 100644 index 0000000..3f9ff4c --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_home_to_filled.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_module_from_filled.xml b/app/apk/src/main/res/drawable/avd_module_from_filled.xml new file mode 100644 index 0000000..3d464b0 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_module_from_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_module_to_filled.xml b/app/apk/src/main/res/drawable/avd_module_to_filled.xml new file mode 100644 index 0000000..bab708f --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_module_to_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_settings_from_filled.xml b/app/apk/src/main/res/drawable/avd_settings_from_filled.xml new file mode 100644 index 0000000..0b5bea4 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_settings_from_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_settings_to_filled.xml b/app/apk/src/main/res/drawable/avd_settings_to_filled.xml new file mode 100644 index 0000000..a5fb8da --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_settings_to_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_superuser_from_filled.xml b/app/apk/src/main/res/drawable/avd_superuser_from_filled.xml new file mode 100644 index 0000000..e5e6372 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_superuser_from_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/avd_superuser_to_filled.xml b/app/apk/src/main/res/drawable/avd_superuser_to_filled.xml new file mode 100644 index 0000000..287e9a9 --- /dev/null +++ b/app/apk/src/main/res/drawable/avd_superuser_to_filled.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/bg_line_bottom_rounded.xml b/app/apk/src/main/res/drawable/bg_line_bottom_rounded.xml new file mode 100644 index 0000000..b7232f2 --- /dev/null +++ b/app/apk/src/main/res/drawable/bg_line_bottom_rounded.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/bg_line_top_rounded.xml b/app/apk/src/main/res/drawable/bg_line_top_rounded.xml new file mode 100644 index 0000000..ec33265 --- /dev/null +++ b/app/apk/src/main/res/drawable/bg_line_top_rounded.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/bg_selection_circle_green.xml b/app/apk/src/main/res/drawable/bg_selection_circle_green.xml new file mode 100644 index 0000000..38788d1 --- /dev/null +++ b/app/apk/src/main/res/drawable/bg_selection_circle_green.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_action_md2.xml b/app/apk/src/main/res/drawable/ic_action_md2.xml new file mode 100644 index 0000000..6cb3671 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_action_md2.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/apk/src/main/res/drawable/ic_back_md2.xml b/app/apk/src/main/res/drawable/ic_back_md2.xml new file mode 100644 index 0000000..2342f12 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_back_md2.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/apk/src/main/res/drawable/ic_bug_filled_md2.xml b/app/apk/src/main/res/drawable/ic_bug_filled_md2.xml new file mode 100644 index 0000000..6849c1b --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_bug_filled_md2.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/apk/src/main/res/drawable/ic_bug_md2.xml b/app/apk/src/main/res/drawable/ic_bug_md2.xml new file mode 100644 index 0000000..e9ea123 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_bug_md2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/app/apk/src/main/res/drawable/ic_bug_outlined_md2.xml b/app/apk/src/main/res/drawable/ic_bug_outlined_md2.xml new file mode 100644 index 0000000..2c9a0a7 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_bug_outlined_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_check_circle_checked_md2.xml b/app/apk/src/main/res/drawable/ic_check_circle_checked_md2.xml new file mode 100644 index 0000000..926f8e6 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_check_circle_checked_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_check_circle_md2.xml b/app/apk/src/main/res/drawable/ic_check_circle_md2.xml new file mode 100644 index 0000000..09f36ee --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_check_circle_md2.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_check_circle_unchecked_md2.xml b/app/apk/src/main/res/drawable/ic_check_circle_unchecked_md2.xml new file mode 100644 index 0000000..1fae275 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_check_circle_unchecked_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_check_md2.xml b/app/apk/src/main/res/drawable/ic_check_md2.xml new file mode 100644 index 0000000..ff66457 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_check_md2.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_close_md2.xml b/app/apk/src/main/res/drawable/ic_close_md2.xml new file mode 100644 index 0000000..786303a --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_close_md2.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/apk/src/main/res/drawable/ic_day.xml b/app/apk/src/main/res/drawable/ic_day.xml new file mode 100644 index 0000000..2899017 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_day.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_day_night.xml b/app/apk/src/main/res/drawable/ic_day_night.xml new file mode 100644 index 0000000..8452fab --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_day_night.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_delete_md2.xml b/app/apk/src/main/res/drawable/ic_delete_md2.xml new file mode 100644 index 0000000..e451bfa --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_delete_md2.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/apk/src/main/res/drawable/ic_download_md2.xml b/app/apk/src/main/res/drawable/ic_download_md2.xml new file mode 100644 index 0000000..5d3c384 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_download_md2.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_folder_list.xml b/app/apk/src/main/res/drawable/ic_folder_list.xml new file mode 100644 index 0000000..7c13a42 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_folder_list.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_forth_md2.xml b/app/apk/src/main/res/drawable/ic_forth_md2.xml new file mode 100644 index 0000000..261bcc7 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_forth_md2.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/apk/src/main/res/drawable/ic_home_filled_md2.xml b/app/apk/src/main/res/drawable/ic_home_filled_md2.xml new file mode 100644 index 0000000..0dafe8a --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_home_filled_md2.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/apk/src/main/res/drawable/ic_home_md2.xml b/app/apk/src/main/res/drawable/ic_home_md2.xml new file mode 100644 index 0000000..a5d90b4 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_home_md2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_home_outlined_md2.xml b/app/apk/src/main/res/drawable/ic_home_outlined_md2.xml new file mode 100644 index 0000000..38bad7c --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_home_outlined_md2.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_install.xml b/app/apk/src/main/res/drawable/ic_install.xml new file mode 100644 index 0000000..9e71aa1 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_install.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_manager.xml b/app/apk/src/main/res/drawable/ic_manager.xml new file mode 100644 index 0000000..001df45 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_manager.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/apk/src/main/res/drawable/ic_module_filled_md2.xml b/app/apk/src/main/res/drawable/ic_module_filled_md2.xml new file mode 100644 index 0000000..8d4c14e --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_module_filled_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_module_md2.xml b/app/apk/src/main/res/drawable/ic_module_md2.xml new file mode 100644 index 0000000..d93278f --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_module_md2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_module_outlined_md2.xml b/app/apk/src/main/res/drawable/ic_module_outlined_md2.xml new file mode 100644 index 0000000..fd83b65 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_module_outlined_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_module_storage_md2.xml b/app/apk/src/main/res/drawable/ic_module_storage_md2.xml new file mode 100644 index 0000000..9505cdd --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_module_storage_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_night.xml b/app/apk/src/main/res/drawable/ic_night.xml new file mode 100644 index 0000000..c31bd05 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_night.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_notifications_md2.xml b/app/apk/src/main/res/drawable/ic_notifications_md2.xml new file mode 100644 index 0000000..c551412 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_notifications_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_paint.xml b/app/apk/src/main/res/drawable/ic_paint.xml new file mode 100644 index 0000000..9917845 --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_paint.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_restart.xml b/app/apk/src/main/res/drawable/ic_restart.xml new file mode 100644 index 0000000..b4f8eae --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_restart.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/apk/src/main/res/drawable/ic_save_md2.xml b/app/apk/src/main/res/drawable/ic_save_md2.xml new file mode 100644 index 0000000..81d0e3b --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_save_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_search_md2.xml b/app/apk/src/main/res/drawable/ic_search_md2.xml new file mode 100644 index 0000000..d290dae --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_search_md2.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/apk/src/main/res/drawable/ic_settings_filled_md2.xml b/app/apk/src/main/res/drawable/ic_settings_filled_md2.xml new file mode 100644 index 0000000..6044b0f --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_settings_filled_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_settings_md2.xml b/app/apk/src/main/res/drawable/ic_settings_md2.xml new file mode 100644 index 0000000..9d294bf --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_settings_md2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_settings_outlined_md2.xml b/app/apk/src/main/res/drawable/ic_settings_outlined_md2.xml new file mode 100644 index 0000000..6c20efa --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_settings_outlined_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_superuser_filled_md2.xml b/app/apk/src/main/res/drawable/ic_superuser_filled_md2.xml new file mode 100644 index 0000000..dde6afe --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_superuser_filled_md2.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/apk/src/main/res/drawable/ic_superuser_md2.xml b/app/apk/src/main/res/drawable/ic_superuser_md2.xml new file mode 100644 index 0000000..374959c --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_superuser_md2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_superuser_outlined_md2.xml b/app/apk/src/main/res/drawable/ic_superuser_outlined_md2.xml new file mode 100644 index 0000000..3c279cc --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_superuser_outlined_md2.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/apk/src/main/res/drawable/ic_update_md2.xml b/app/apk/src/main/res/drawable/ic_update_md2.xml new file mode 100644 index 0000000..7595bcf --- /dev/null +++ b/app/apk/src/main/res/drawable/ic_update_md2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/apk/src/main/res/layout/activity_main_md2.xml b/app/apk/src/main/res/layout/activity_main_md2.xml new file mode 100644 index 0000000..83a16f7 --- /dev/null +++ b/app/apk/src/main/res/layout/activity_main_md2.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/apk/src/main/res/layout/activity_request.xml b/app/apk/src/main/res/layout/activity_request.xml new file mode 100644 index 0000000..8dbe49d --- /dev/null +++ b/app/apk/src/main/res/layout/activity_request.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +