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 @@
+
+
+[](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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/dialog_magisk_base.xml b/app/apk/src/main/res/layout/dialog_magisk_base.xml
new file mode 100644
index 0000000..e88b109
--- /dev/null
+++ b/app/apk/src/main/res/layout/dialog_magisk_base.xml
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/dialog_settings_app_name.xml b/app/apk/src/main/res/layout/dialog_settings_app_name.xml
new file mode 100644
index 0000000..dc0b830
--- /dev/null
+++ b/app/apk/src/main/res/layout/dialog_settings_app_name.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/dialog_settings_download_path.xml b/app/apk/src/main/res/layout/dialog_settings_download_path.xml
new file mode 100644
index 0000000..1a97385
--- /dev/null
+++ b/app/apk/src/main/res/layout/dialog_settings_download_path.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/dialog_settings_update_channel.xml b/app/apk/src/main/res/layout/dialog_settings_update_channel.xml
new file mode 100644
index 0000000..305485f
--- /dev/null
+++ b/app/apk/src/main/res/layout/dialog_settings_update_channel.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_action_md2.xml b/app/apk/src/main/res/layout/fragment_action_md2.xml
new file mode 100644
index 0000000..f50e509
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_action_md2.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_deny_md2.xml b/app/apk/src/main/res/layout/fragment_deny_md2.xml
new file mode 100644
index 0000000..89a202a
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_deny_md2.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_flash_md2.xml b/app/apk/src/main/res/layout/fragment_flash_md2.xml
new file mode 100644
index 0000000..6ce2ff1
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_flash_md2.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_home_md2.xml b/app/apk/src/main/res/layout/fragment_home_md2.xml
new file mode 100644
index 0000000..a3d2c60
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_home_md2.xml
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_install_md2.xml b/app/apk/src/main/res/layout/fragment_install_md2.xml
new file mode 100644
index 0000000..7a73643
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_install_md2.xml
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_log_md2.xml b/app/apk/src/main/res/layout/fragment_log_md2.xml
new file mode 100644
index 0000000..324d66a
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_log_md2.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_module_md2.xml b/app/apk/src/main/res/layout/fragment_module_md2.xml
new file mode 100644
index 0000000..11ceb23
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_module_md2.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_settings_md2.xml b/app/apk/src/main/res/layout/fragment_settings_md2.xml
new file mode 100644
index 0000000..10aa02c
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_settings_md2.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_superuser_md2.xml b/app/apk/src/main/res/layout/fragment_superuser_md2.xml
new file mode 100644
index 0000000..3f37482
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_superuser_md2.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/fragment_theme_md2.xml b/app/apk/src/main/res/layout/fragment_theme_md2.xml
new file mode 100644
index 0000000..bf4babb
--- /dev/null
+++ b/app/apk/src/main/res/layout/fragment_theme_md2.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/include_home_magisk.xml b/app/apk/src/main/res/layout/include_home_magisk.xml
new file mode 100644
index 0000000..e71b5ce
--- /dev/null
+++ b/app/apk/src/main/res/layout/include_home_magisk.xml
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/include_home_manager.xml b/app/apk/src/main/res/layout/include_home_manager.xml
new file mode 100644
index 0000000..8acee96
--- /dev/null
+++ b/app/apk/src/main/res/layout/include_home_manager.xml
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/include_log_magisk.xml b/app/apk/src/main/res/layout/include_log_magisk.xml
new file mode 100644
index 0000000..b31567a
--- /dev/null
+++ b/app/apk/src/main/res/layout/include_log_magisk.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/include_log_superuser.xml b/app/apk/src/main/res/layout/include_log_superuser.xml
new file mode 100644
index 0000000..0a70e8a
--- /dev/null
+++ b/app/apk/src/main/res/layout/include_log_superuser.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_console_md2.xml b/app/apk/src/main/res/layout/item_console_md2.xml
new file mode 100644
index 0000000..f86c3d8
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_console_md2.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_developer.xml b/app/apk/src/main/res/layout/item_developer.xml
new file mode 100644
index 0000000..eee823d
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_developer.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_hide_md2.xml b/app/apk/src/main/res/layout/item_hide_md2.xml
new file mode 100644
index 0000000..280ee19
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_hide_md2.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_hide_process_md2.xml b/app/apk/src/main/res/layout/item_hide_process_md2.xml
new file mode 100644
index 0000000..5317e80
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_hide_process_md2.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_icon_link.xml b/app/apk/src/main/res/layout/item_icon_link.xml
new file mode 100644
index 0000000..386c248
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_icon_link.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_list_single_line.xml b/app/apk/src/main/res/layout/item_list_single_line.xml
new file mode 100644
index 0000000..c310505
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_list_single_line.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_log_access_md2.xml b/app/apk/src/main/res/layout/item_log_access_md2.xml
new file mode 100644
index 0000000..6aaaaf1
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_log_access_md2.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_log_textview.xml b/app/apk/src/main/res/layout/item_log_textview.xml
new file mode 100644
index 0000000..fa84705
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_log_textview.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_log_track_md2.xml b/app/apk/src/main/res/layout/item_log_track_md2.xml
new file mode 100644
index 0000000..5e0f4f0
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_log_track_md2.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_module_download.xml b/app/apk/src/main/res/layout/item_module_download.xml
new file mode 100644
index 0000000..6f444bc
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_module_download.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_module_md2.xml b/app/apk/src/main/res/layout/item_module_md2.xml
new file mode 100644
index 0000000..c6304a9
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_module_md2.xml
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_policy_md2.xml b/app/apk/src/main/res/layout/item_policy_md2.xml
new file mode 100644
index 0000000..f4b1f54
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_policy_md2.xml
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_settings.xml b/app/apk/src/main/res/layout/item_settings.xml
new file mode 100644
index 0000000..f547e08
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_settings.xml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_settings_section.xml b/app/apk/src/main/res/layout/item_settings_section.xml
new file mode 100644
index 0000000..3dfe479
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_settings_section.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_spinner.xml b/app/apk/src/main/res/layout/item_spinner.xml
new file mode 100644
index 0000000..0776ef8
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_spinner.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/apk/src/main/res/layout/item_tappable_headline.xml b/app/apk/src/main/res/layout/item_tappable_headline.xml
new file mode 100644
index 0000000..caecf86
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_tappable_headline.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_text.xml b/app/apk/src/main/res/layout/item_text.xml
new file mode 100644
index 0000000..1ea722e
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_text.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_theme.xml b/app/apk/src/main/res/layout/item_theme.xml
new file mode 100644
index 0000000..23ef7a1
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_theme.xml
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/item_theme_container.xml b/app/apk/src/main/res/layout/item_theme_container.xml
new file mode 100644
index 0000000..9f22740
--- /dev/null
+++ b/app/apk/src/main/res/layout/item_theme_container.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/layout/markdown_window_md2.xml b/app/apk/src/main/res/layout/markdown_window_md2.xml
new file mode 100644
index 0000000..da63f55
--- /dev/null
+++ b/app/apk/src/main/res/layout/markdown_window_md2.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/menu/menu_bottom_nav.xml b/app/apk/src/main/res/menu/menu_bottom_nav.xml
new file mode 100644
index 0000000..06d1937
--- /dev/null
+++ b/app/apk/src/main/res/menu/menu_bottom_nav.xml
@@ -0,0 +1,29 @@
+
+
diff --git a/app/apk/src/main/res/menu/menu_deny_md2.xml b/app/apk/src/main/res/menu/menu_deny_md2.xml
new file mode 100644
index 0000000..b50e921
--- /dev/null
+++ b/app/apk/src/main/res/menu/menu_deny_md2.xml
@@ -0,0 +1,20 @@
+
+
diff --git a/app/apk/src/main/res/menu/menu_flash.xml b/app/apk/src/main/res/menu/menu_flash.xml
new file mode 100644
index 0000000..8368a4a
--- /dev/null
+++ b/app/apk/src/main/res/menu/menu_flash.xml
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/app/apk/src/main/res/menu/menu_home_md2.xml b/app/apk/src/main/res/menu/menu_home_md2.xml
new file mode 100644
index 0000000..0a2f398
--- /dev/null
+++ b/app/apk/src/main/res/menu/menu_home_md2.xml
@@ -0,0 +1,17 @@
+
+
diff --git a/app/apk/src/main/res/menu/menu_log_md2.xml b/app/apk/src/main/res/menu/menu_log_md2.xml
new file mode 100644
index 0000000..0356092
--- /dev/null
+++ b/app/apk/src/main/res/menu/menu_log_md2.xml
@@ -0,0 +1,17 @@
+
+
\ No newline at end of file
diff --git a/app/apk/src/main/res/menu/menu_reboot.xml b/app/apk/src/main/res/menu/menu_reboot.xml
new file mode 100644
index 0000000..6265023
--- /dev/null
+++ b/app/apk/src/main/res/menu/menu_reboot.xml
@@ -0,0 +1,34 @@
+
+
diff --git a/app/apk/src/main/res/navigation/main.xml b/app/apk/src/main/res/navigation/main.xml
new file mode 100644
index 0000000..d769c5c
--- /dev/null
+++ b/app/apk/src/main/res/navigation/main.xml
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values-night/styles_md2.xml b/app/apk/src/main/res/values-night/styles_md2.xml
new file mode 100644
index 0000000..6915081
--- /dev/null
+++ b/app/apk/src/main/res/values-night/styles_md2.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values-night/themes_md2.xml b/app/apk/src/main/res/values-night/themes_md2.xml
new file mode 100644
index 0000000..e4b7227
--- /dev/null
+++ b/app/apk/src/main/res/values-night/themes_md2.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/apk/src/main/res/values-v27/themes.xml b/app/apk/src/main/res/values-v27/themes.xml
new file mode 100644
index 0000000..a922263
--- /dev/null
+++ b/app/apk/src/main/res/values-v27/themes.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/attrs.xml b/app/apk/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..d7b4be8
--- /dev/null
+++ b/app/apk/src/main/res/values/attrs.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/dimens.xml b/app/apk/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..a297eb9
--- /dev/null
+++ b/app/apk/src/main/res/values/dimens.xml
@@ -0,0 +1,17 @@
+
+
+
+ 16dp
+
+ 2dp
+ 4dp
+ 8dp
+ 12dp
+ 16dp
+ 32dp
+ 48dp
+
+ 8dp
+
+ 56dp
+
diff --git a/app/apk/src/main/res/values/ids.xml b/app/apk/src/main/res/values/ids.xml
new file mode 100644
index 0000000..e89a681
--- /dev/null
+++ b/app/apk/src/main/res/values/ids.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/apk/src/main/res/values/styles_md2.xml b/app/apk/src/main/res/values/styles_md2.xml
new file mode 100644
index 0000000..4966d75
--- /dev/null
+++ b/app/apk/src/main/res/values/styles_md2.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/styles_md2_appearance.xml b/app/apk/src/main/res/values/styles_md2_appearance.xml
new file mode 100644
index 0000000..fde3a5a
--- /dev/null
+++ b/app/apk/src/main/res/values/styles_md2_appearance.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/styles_md2_impl.xml b/app/apk/src/main/res/values/styles_md2_impl.xml
new file mode 100644
index 0000000..15e4b36
--- /dev/null
+++ b/app/apk/src/main/res/values/styles_md2_impl.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/styles_view_md2.xml b/app/apk/src/main/res/values/styles_view_md2.xml
new file mode 100644
index 0000000..ae5e969
--- /dev/null
+++ b/app/apk/src/main/res/values/styles_view_md2.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/theme_overlay.xml b/app/apk/src/main/res/values/theme_overlay.xml
new file mode 100644
index 0000000..b53fbe6
--- /dev/null
+++ b/app/apk/src/main/res/values/theme_overlay.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/themes.xml b/app/apk/src/main/res/values/themes.xml
new file mode 100644
index 0000000..09078e3
--- /dev/null
+++ b/app/apk/src/main/res/values/themes.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/themes_md2.xml b/app/apk/src/main/res/values/themes_md2.xml
new file mode 100644
index 0000000..e245151
--- /dev/null
+++ b/app/apk/src/main/res/values/themes_md2.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/apk/src/main/res/values/themes_override.xml b/app/apk/src/main/res/values/themes_override.xml
new file mode 100644
index 0000000..ef0e9b5
--- /dev/null
+++ b/app/apk/src/main/res/values/themes_override.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..dba72da
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ id("MagiskPlugin")
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.layout.buildDirectory)
+
+ subprojects.forEach {
+ dependsOn(":${it.name}:clean")
+ }
+}
diff --git a/app/buildSrc/.gitignore b/app/buildSrc/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/app/buildSrc/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/app/buildSrc/build.gradle.kts b/app/buildSrc/build.gradle.kts
new file mode 100644
index 0000000..ee451ef
--- /dev/null
+++ b/app/buildSrc/build.gradle.kts
@@ -0,0 +1,29 @@
+import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
+
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ google()
+ mavenCentral()
+}
+
+gradlePlugin {
+ plugins {
+ register("MagiskPlugin") {
+ id = "MagiskPlugin"
+ implementationClass = "MagiskPlugin"
+ }
+ }
+}
+
+dependencies {
+ implementation(kotlin("gradle-plugin", libs.versions.kotlin.get()))
+ implementation(libs.android.gradle.plugin)
+ implementation(libs.ksp.plugin)
+ implementation(libs.navigation.safe.args.plugin)
+ implementation(libs.lsparanoid.plugin)
+ implementation(libs.moshi.plugin)
+ implementation(libs.jgit)
+}
diff --git a/app/buildSrc/settings.gradle.kts b/app/buildSrc/settings.gradle.kts
new file mode 100644
index 0000000..b5a0fab
--- /dev/null
+++ b/app/buildSrc/settings.gradle.kts
@@ -0,0 +1,7 @@
+dependencyResolutionManagement {
+ versionCatalogs {
+ create("libs") {
+ from(files("../gradle/libs.versions.toml"))
+ }
+ }
+}
diff --git a/app/buildSrc/src/main/java/AddCommentTask.kt b/app/buildSrc/src/main/java/AddCommentTask.kt
new file mode 100644
index 0000000..c5af1ab
--- /dev/null
+++ b/app/buildSrc/src/main/java/AddCommentTask.kt
@@ -0,0 +1,77 @@
+import com.android.build.api.artifact.ArtifactTransformationRequest
+import com.android.build.api.dsl.ApkSigningConfig
+import com.android.builder.internal.packaging.IncrementalPackager
+import com.android.tools.build.apkzlib.sign.SigningExtension
+import com.android.tools.build.apkzlib.sign.SigningOptions
+import com.android.tools.build.apkzlib.zfile.ZFiles
+import com.android.tools.build.apkzlib.zip.ZFileOptions
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+import java.io.File
+import java.security.KeyStore
+import java.security.cert.X509Certificate
+import java.util.jar.JarFile
+
+abstract class AddCommentTask: DefaultTask() {
+ @get:Input
+ abstract val comment: Property
+
+ @get:Input
+ abstract val signingConfig: Property
+
+ @get:InputFiles
+ abstract val apkFolder: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val outFolder: DirectoryProperty
+
+ @get:Internal
+ abstract val transformationRequest: Property>
+
+ @TaskAction
+ fun taskAction() = transformationRequest.get().submit(this) { artifact ->
+ val inFile = File(artifact.outputFile)
+ val outFile = outFolder.file(inFile.name).get().asFile
+
+ val privateKey = signingConfig.get().getPrivateKey()
+ val signingOptions = SigningOptions.builder()
+ .setMinSdkVersion(0)
+ .setV1SigningEnabled(true)
+ .setV2SigningEnabled(true)
+ .setKey(privateKey.privateKey)
+ .setCertificates(privateKey.certificate as X509Certificate)
+ .setValidation(SigningOptions.Validation.ASSUME_INVALID)
+ .build()
+ val options = ZFileOptions().apply {
+ noTimestamps = true
+ autoSortFiles = true
+ }
+ outFile.parentFile?.mkdirs()
+ inFile.copyTo(outFile, overwrite = true)
+ ZFiles.apk(outFile, options).use {
+ SigningExtension(signingOptions).register(it)
+ it.eocdComment = comment.get().toByteArray()
+ it.get(IncrementalPackager.APP_METADATA_ENTRY_PATH)?.delete()
+ it.get(IncrementalPackager.VERSION_CONTROL_INFO_ENTRY_PATH)?.delete()
+ it.get(JarFile.MANIFEST_NAME)?.delete()
+ }
+
+ outFile
+ }
+
+ private fun ApkSigningConfig.getPrivateKey(): KeyStore.PrivateKeyEntry {
+ val keyStore = KeyStore.getInstance(storeType ?: KeyStore.getDefaultType())
+ storeFile!!.inputStream().use {
+ keyStore.load(it, storePassword!!.toCharArray())
+ }
+ val keyPwdArray = keyPassword!!.toCharArray()
+ val entry = keyStore.getEntry(keyAlias!!, KeyStore.PasswordProtection(keyPwdArray))
+ return entry as KeyStore.PrivateKeyEntry
+ }
+}
\ No newline at end of file
diff --git a/app/buildSrc/src/main/java/DesugarClassVisitorFactory.kt b/app/buildSrc/src/main/java/DesugarClassVisitorFactory.kt
new file mode 100644
index 0000000..75a8ccb
--- /dev/null
+++ b/app/buildSrc/src/main/java/DesugarClassVisitorFactory.kt
@@ -0,0 +1,122 @@
+import com.android.build.api.instrumentation.AsmClassVisitorFactory
+import com.android.build.api.instrumentation.ClassContext
+import com.android.build.api.instrumentation.ClassData
+import com.android.build.api.instrumentation.InstrumentationParameters
+import org.objectweb.asm.ClassVisitor
+import org.objectweb.asm.MethodVisitor
+import org.objectweb.asm.Opcodes
+import org.objectweb.asm.Opcodes.ASM9
+
+private const val DESUGAR_CLASS_NAME = "com.topjohnwu.magisk.core.utils.Desugar"
+private const val ZIP_ENTRY_CLASS_NAME = "java.util.zip.ZipEntry"
+private const val ZIP_OUT_STREAM_CLASS_NAME = "org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream"
+private const val ZIP_UTIL_CLASS_NAME = "org/apache/commons/compress/archivers/zip/ZipUtil"
+private const val ZIP_ENTRY_GET_TIME_DESC = "()Ljava/nio/file/attribute/FileTime;"
+private const val DESUGAR_GET_TIME_DESC =
+ "(Ljava/util/zip/ZipEntry;)Ljava/nio/file/attribute/FileTime;"
+
+private fun ClassData.isTypeOf(name: String) = className == name || superClasses.contains(name)
+
+abstract class DesugarClassVisitorFactory : AsmClassVisitorFactory {
+ override fun createClassVisitor(
+ classContext: ClassContext,
+ nextClassVisitor: ClassVisitor
+ ): ClassVisitor {
+ return if (classContext.currentClassData.className == ZIP_OUT_STREAM_CLASS_NAME) {
+ ZipEntryPatcher(classContext, ZipOutputStreamPatcher(nextClassVisitor))
+ } else {
+ ZipEntryPatcher(classContext, nextClassVisitor)
+ }
+ }
+
+ override fun isInstrumentable(classData: ClassData) = classData.className != DESUGAR_CLASS_NAME
+
+ // Patch ALL references to ZipEntry#getXXXTime
+ class ZipEntryPatcher(
+ private val classContext: ClassContext,
+ cv: ClassVisitor
+ ) : ClassVisitor(ASM9, cv) {
+ override fun visitMethod(
+ access: Int,
+ name: String?,
+ descriptor: String?,
+ signature: String?,
+ exceptions: Array?
+ ) = MethodPatcher(super.visitMethod(access, name, descriptor, signature, exceptions))
+
+ inner class MethodPatcher(mv: MethodVisitor?) : MethodVisitor(ASM9, mv) {
+ override fun visitMethodInsn(
+ opcode: Int,
+ owner: String,
+ name: String,
+ descriptor: String,
+ isInterface: Boolean
+ ) {
+ if (!process(owner, name, descriptor)) {
+ super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
+ }
+ }
+
+ private fun process(owner: String, name: String, descriptor: String): Boolean {
+ val classData = classContext.loadClassData(owner.replace("/", ".")) ?: return false
+ if (!classData.isTypeOf(ZIP_ENTRY_CLASS_NAME))
+ return false
+ if (descriptor != ZIP_ENTRY_GET_TIME_DESC)
+ return false
+ return when (name) {
+ "getLastModifiedTime", "getLastAccessTime", "getCreationTime" -> {
+ mv.visitMethodInsn(
+ Opcodes.INVOKESTATIC,
+ DESUGAR_CLASS_NAME.replace('.', '/'),
+ name,
+ DESUGAR_GET_TIME_DESC,
+ false
+ )
+ true
+ }
+ else -> false
+ }
+ }
+ }
+ }
+
+ // Patch ZipArchiveOutputStream#copyFromZipInputStream
+ class ZipOutputStreamPatcher(cv: ClassVisitor) : ClassVisitor(ASM9, cv) {
+ override fun visitMethod(
+ access: Int,
+ name: String,
+ descriptor: String,
+ signature: String?,
+ exceptions: Array?
+ ): MethodVisitor? {
+ return if (name == "copyFromZipInputStream") {
+ MethodPatcher(super.visitMethod(access, name, descriptor, signature, exceptions))
+ } else {
+ super.visitMethod(access, name, descriptor, signature, exceptions)
+ }
+ }
+
+ class MethodPatcher(mv: MethodVisitor?) : MethodVisitor(ASM9, mv) {
+ override fun visitMethodInsn(
+ opcode: Int,
+ owner: String,
+ name: String,
+ descriptor: String?,
+ isInterface: Boolean
+ ) {
+ if (owner == ZIP_UTIL_CLASS_NAME && name == "checkRequestedFeatures") {
+ // Redirect
+ mv.visitMethodInsn(
+ Opcodes.INVOKESTATIC,
+ DESUGAR_CLASS_NAME.replace('.', '/'),
+ name,
+ descriptor,
+ false
+ )
+ } else {
+ super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
+ }
+ }
+ }
+ }
+}
diff --git a/app/buildSrc/src/main/java/Plugin.kt b/app/buildSrc/src/main/java/Plugin.kt
new file mode 100644
index 0000000..a1d9925
--- /dev/null
+++ b/app/buildSrc/src/main/java/Plugin.kt
@@ -0,0 +1,59 @@
+
+import org.eclipse.jgit.internal.storage.file.FileRepository
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.provideDelegate
+import java.io.File
+import java.util.Properties
+import java.util.Random
+
+// Set non-zero value here to fix the random seed for reproducible builds
+// CI builds are always reproducible
+val RAND_SEED = if (System.getenv("CI") != null) 42 else 0
+lateinit var RANDOM: Random
+
+private val props = Properties()
+private var commitHash = ""
+private val supportAbis = setOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64", "riscv64")
+private val defaultAbis = setOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
+
+object Config {
+ operator fun get(key: String): String? {
+ val v = props[key] as? String ?: return null
+ return v.ifBlank { null }
+ }
+
+ fun contains(key: String) = get(key) != null
+
+ val version: String get() = get("version") ?: commitHash
+ val versionCode: Int get() = get("magisk.versionCode")!!.toInt()
+ val stubVersion: String get() = get("magisk.stubVersion")!!
+ val abiList: Set get() {
+ val abiList = get("abiList") ?: return defaultAbis
+ return abiList.split(Regex("\\s*,\\s*")).toSet() intersect supportAbis
+ }
+}
+
+fun Project.rootFile(path: String): File {
+ val file = File(path)
+ return if (file.isAbsolute) file
+ else File(rootProject.file(".."), path)
+}
+
+class MagiskPlugin : Plugin {
+ override fun apply(project: Project) = project.applyPlugin()
+
+ private fun Project.applyPlugin() {
+ initRandom(rootProject.file("dict.txt"))
+ props.clear()
+ rootProject.file("gradle.properties").inputStream().use { props.load(it) }
+ val configPath: String? by this
+ val config = rootFile(configPath ?: "config.prop")
+ if (config.exists())
+ config.inputStream().use { props.load(it) }
+
+ val repo = FileRepository(rootFile(".git"))
+ val refId = repo.refDatabase.exactRef("HEAD").objectId
+ commitHash = repo.newObjectReader().abbreviate(refId, 8).name()
+ }
+}
diff --git a/app/buildSrc/src/main/java/Setup.kt b/app/buildSrc/src/main/java/Setup.kt
new file mode 100644
index 0000000..d8c2d60
--- /dev/null
+++ b/app/buildSrc/src/main/java/Setup.kt
@@ -0,0 +1,330 @@
+import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.instrumentation.FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS
+import com.android.build.api.instrumentation.InstrumentationScope
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.gradle.BaseExtension
+import com.android.build.gradle.LibraryExtension
+import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
+import org.apache.tools.ant.filters.FixCrLfFilter
+import org.gradle.api.Action
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.tasks.Copy
+import org.gradle.api.tasks.Delete
+import org.gradle.api.tasks.StopExecutionException
+import org.gradle.api.tasks.Sync
+import org.gradle.kotlin.dsl.assign
+import org.gradle.kotlin.dsl.exclude
+import org.gradle.kotlin.dsl.filter
+import org.gradle.kotlin.dsl.get
+import org.gradle.kotlin.dsl.getValue
+import org.gradle.kotlin.dsl.named
+import org.gradle.kotlin.dsl.provideDelegate
+import org.gradle.kotlin.dsl.register
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.net.URI
+import java.security.MessageDigest
+import java.util.HexFormat
+import java.util.zip.Deflater
+import java.util.zip.DeflaterOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+private fun Project.androidBase(configure: Action) =
+ extensions.configure("android", configure)
+
+private fun Project.android(configure: Action) =
+ extensions.configure("android", configure)
+
+internal val Project.androidApp: BaseAppModuleExtension
+ get() = extensions["android"] as BaseAppModuleExtension
+
+private val Project.androidLib: LibraryExtension
+ get() = extensions["android"] as LibraryExtension
+
+internal val Project.androidComponents
+ get() = extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
+
+fun Project.setupCommon() {
+ androidBase {
+ compileSdkVersion(36)
+ buildToolsVersion = "36.0.0"
+ ndkPath = "$sdkDirectory/ndk/magisk"
+ ndkVersion = "29.0.13846066"
+
+ defaultConfig {
+ minSdk = 23
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ packagingOptions {
+ resources {
+ excludes += arrayOf(
+ "/META-INF/*",
+ "/META-INF/androidx/**",
+ "/META-INF/versions/**",
+ "/org/bouncycastle/**",
+ "/org/apache/commons/**",
+ "/kotlin/**",
+ "/kotlinx/**",
+ "/okhttp3/**",
+ "/*.txt",
+ "/*.bin",
+ "/*.json",
+ )
+ }
+ }
+ }
+
+ configurations.all {
+ exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk7")
+ exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk8")
+ }
+
+ tasks.withType {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_21
+ }
+ }
+}
+
+private fun Project.downloadFile(url: String, checksum: String): File {
+ val file = layout.buildDirectory.file(checksum).get().asFile
+ if (file.exists()) {
+ val md = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { md.update(it.readAllBytes()) }
+ val hash = HexFormat.of().formatHex(md.digest())
+ if (hash != checksum) {
+ file.delete()
+ }
+ }
+ if (!file.exists()) {
+ file.parentFile.mkdirs()
+ URI(url).toURL().openStream().use { dl ->
+ file.outputStream().use {
+ dl.copyTo(it)
+ }
+ }
+ }
+ return file
+}
+
+const val BUSYBOX_DOWNLOAD_URL =
+ "https://github.com/topjohnwu/magisk-files/releases/download/files/busybox-1.36.1.1.zip"
+const val BUSYBOX_ZIP_CHECKSUM =
+ "b4d0551feabaf314e53c79316c980e8f66432e9fb91a69dbbf10a93564b40951"
+
+fun Project.setupCoreLib() {
+ setupCommon()
+
+ androidLib.libraryVariants.all {
+ val variant = name
+ val variantCapped = name.replaceFirstChar { it.uppercase() }
+ val abiList = Config.abiList
+
+ val syncLibs = tasks.register("sync${variantCapped}JniLibs", Sync::class) {
+ into("src/$variant/jniLibs")
+ for (abi in abiList) {
+ into(abi) {
+ from(rootFile("native/out/$abi")) {
+ include("magiskboot", "magiskinit", "magiskpolicy", "magisk", "libinit-ld.so")
+ rename { if (it.endsWith(".so")) it else "lib$it.so" }
+ }
+ }
+ }
+ from(zipTree(downloadFile(BUSYBOX_DOWNLOAD_URL, BUSYBOX_ZIP_CHECKSUM)))
+ include(abiList.map { "$it/libbusybox.so" })
+ onlyIf {
+ if (inputs.sourceFiles.files.size != abiList.size * 6)
+ throw StopExecutionException("Please build binaries first! (./build.py binary)")
+ true
+ }
+ }
+
+ tasks.getByPath("merge${variantCapped}JniLibFolders").dependsOn(syncLibs)
+
+ val syncResources = tasks.register("sync${variantCapped}Resources", Sync::class) {
+ into("src/$variant/resources/META-INF/com/google/android")
+ from(rootFile("scripts/update_binary.sh")) {
+ rename { "update-binary" }
+ }
+ from(rootFile("scripts/flash_script.sh")) {
+ rename { "updater-script" }
+ }
+ }
+
+ processJavaResourcesProvider.configure { dependsOn(syncResources) }
+
+ val stubTask = tasks.getByPath(":stub:comment$variantCapped")
+ val stubApk = stubTask.outputs.files.asFileTree.filter {
+ it.name.endsWith(".apk")
+ }
+
+ val syncAssets = tasks.register("sync${variantCapped}Assets", Sync::class) {
+ dependsOn(stubTask)
+ inputs.property("version", Config.version)
+ inputs.property("versionCode", Config.versionCode)
+ into("src/$variant/assets")
+ from(rootFile("scripts")) {
+ include("util_functions.sh", "boot_patch.sh", "addon.d.sh",
+ "app_functions.sh", "uninstaller.sh", "module_installer.sh")
+ }
+ from(rootFile("tools/bootctl"))
+ into("chromeos") {
+ from(rootFile("tools/futility"))
+ from(rootFile("tools/keys")) {
+ include("kernel_data_key.vbprivk", "kernel.keyblock")
+ }
+ }
+ from(stubApk) {
+ rename { "stub.apk" }
+ }
+ filesMatching("**/util_functions.sh") {
+ filter {
+ it.replace(
+ "#MAGISK_VERSION_STUB",
+ "MAGISK_VER='${Config.version}'\nMAGISK_VER_CODE=${Config.versionCode}"
+ )
+ }
+ filter("eol" to FixCrLfFilter.CrLf.newInstance("lf"))
+ }
+ }
+ mergeAssetsProvider.configure { dependsOn(syncAssets) }
+ }
+
+ tasks.named("clean") {
+ delete.addAll(listOf("src/main/jniLibs", "src/main/resources", "src/debug", "src/release"))
+ }
+}
+
+fun Project.setupAppCommon() {
+ setupCommon()
+
+ android {
+ signingConfigs {
+ Config["keyStore"]?.also {
+ create("config") {
+ storeFile = rootFile(it)
+ storePassword = Config["keyStorePass"]
+ keyAlias = Config["keyAlias"]
+ keyPassword = Config["keyPass"]
+ }
+ }
+ }
+
+ defaultConfig {
+ targetSdk = 36
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt")
+ )
+ }
+
+ buildTypes {
+ val config = signingConfigs.findByName("config") ?: signingConfigs["debug"]
+ debug {
+ signingConfig = config
+ }
+ release {
+ signingConfig = config
+ }
+ }
+
+ lint {
+ disable += "MissingTranslation"
+ checkReleaseBuilds = false
+ }
+
+ dependenciesInfo {
+ includeInApk = false
+ }
+
+ packaging {
+ jniLibs {
+ useLegacyPackaging = true
+ }
+ }
+ }
+
+ androidComponents.onVariants { variant ->
+ val commentTask = tasks.register(
+ "comment${variant.name.replaceFirstChar { it.uppercase() }}",
+ AddCommentTask::class.java
+ )
+ val transformationRequest = variant.artifacts.use(commentTask)
+ .wiredWithDirectories(AddCommentTask::apkFolder, AddCommentTask::outFolder)
+ .toTransformMany(SingleArtifact.APK)
+ val signingConfig = androidApp.buildTypes.getByName(variant.buildType!!).signingConfig
+ commentTask.configure {
+ this.transformationRequest = transformationRequest
+ this.signingConfig = signingConfig
+ this.comment = "version=${Config.version}\n" +
+ "versionCode=${Config.versionCode}\n" +
+ "stubVersion=${Config.stubVersion}\n"
+ this.outFolder.set(layout.buildDirectory.dir("outputs/apk/${variant.name}"))
+ }
+ }
+}
+
+fun Project.setupMainApk() {
+ setupAppCommon()
+
+ android {
+ namespace = "com.topjohnwu.magisk"
+
+ defaultConfig {
+ applicationId = "com.topjohnwu.magisk"
+ vectorDrawables.useSupportLibrary = true
+ versionName = Config.version
+ versionCode = Config.versionCode
+ ndk {
+ abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64", "riscv64")
+ debugSymbolLevel = "FULL"
+ }
+ }
+
+ androidComponents.onVariants { variant ->
+ variant.instrumentation.apply {
+ setAsmFramesComputationMode(COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS)
+ transformClassesWith(
+ DesugarClassVisitorFactory::class.java, InstrumentationScope.ALL) {}
+ }
+ }
+ }
+}
+
+const val LSPOSED_DOWNLOAD_URL =
+ "https://github.com/LSPosed/LSPosed/releases/download/v1.9.2/LSPosed-v1.9.2-7024-zygisk-release.zip"
+const val LSPOSED_CHECKSUM =
+ "0ebc6bcb465d1c4b44b7220ab5f0252e6b4eb7fe43da74650476d2798bb29622"
+
+const val SHAMIKO_DOWNLOAD_URL =
+ "https://github.com/LSPosed/LSPosed.github.io/releases/download/shamiko-383/Shamiko-v1.2.1-383-release.zip"
+const val SHAMIKO_CHECKSUM =
+ "93754a038c2d8f0e985bad45c7303b96f70a93d8335060e50146f028d3a9b13f"
+
+fun Project.setupTestApk() {
+ setupAppCommon()
+
+ androidApp.applicationVariants.all {
+ val variantCapped = name.replaceFirstChar { it.uppercase() }
+ val dlTask by tasks.register("download${variantCapped}Lsposed", Sync::class) {
+ from(downloadFile(LSPOSED_DOWNLOAD_URL, LSPOSED_CHECKSUM)) {
+ rename { "lsposed.zip" }
+ }
+ from(downloadFile(SHAMIKO_DOWNLOAD_URL, SHAMIKO_CHECKSUM)) {
+ rename { "shamiko.zip" }
+ }
+ into("src/${this@all.name}/assets")
+ }
+ mergeAssetsProvider.configure { dependsOn(dlTask) }
+ }
+}
diff --git a/app/buildSrc/src/main/java/Stub.kt b/app/buildSrc/src/main/java/Stub.kt
new file mode 100644
index 0000000..f229afe
--- /dev/null
+++ b/app/buildSrc/src/main/java/Stub.kt
@@ -0,0 +1,348 @@
+import com.android.build.api.artifact.SingleArtifact
+import org.gradle.api.DefaultTask
+import org.gradle.api.Project
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Delete
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.kotlin.dsl.assign
+import org.gradle.kotlin.dsl.named
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.PrintStream
+import java.security.SecureRandom
+import java.util.Random
+import java.util.zip.Deflater
+import java.util.zip.DeflaterOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+import javax.crypto.Cipher
+import javax.crypto.CipherOutputStream
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+import kotlin.random.asKotlinRandom
+
+private val kRANDOM get() = RANDOM.asKotlinRandom()
+
+private val c1 = mutableListOf()
+private val c2 = mutableListOf()
+private val c3 = mutableListOf()
+
+fun initRandom(dict: File) {
+ RANDOM = if (RAND_SEED != 0) Random(RAND_SEED.toLong()) else SecureRandom()
+ c1.clear()
+ c2.clear()
+ c3.clear()
+ for (a in chain('a'..'z', 'A'..'Z')) {
+ if (a != 'a' && a != 'A') {
+ c1.add("$a")
+ }
+ for (b in chain('a'..'z', 'A'..'Z', '0'..'9')) {
+ c2.add("$a$b")
+ for (c in chain('a'..'z', 'A'..'Z', '0'..'9')) {
+ c3.add("$a$b$c")
+ }
+ }
+ }
+ c1.shuffle(RANDOM)
+ c2.shuffle(RANDOM)
+ c3.shuffle(RANDOM)
+ PrintStream(dict).use {
+ for (c in chain(c1, c2, c3)) {
+ it.println(c)
+ }
+ }
+}
+
+private fun chain(vararg iters: Iterable) = sequence {
+ iters.forEach { it.forEach { v -> yield(v) } }
+}
+
+private fun PrintStream.byteField(name: String, bytes: ByteArray) {
+ println("public static byte[] $name() {")
+ print("byte[] buf = {")
+ print(bytes.joinToString(",") { it.toString() })
+ println("};")
+ println("return buf;")
+ println("}")
+}
+
+@CacheableTask
+private abstract class ManifestUpdater: DefaultTask() {
+ @get:Input
+ abstract val applicationId: Property
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val mergedManifest: RegularFileProperty
+
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val factoryClassDir: DirectoryProperty
+
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val appClassDir: DirectoryProperty
+
+ @get:OutputFile
+ abstract val outputManifest: RegularFileProperty
+
+ @TaskAction
+ fun taskAction() {
+ fun String.ind(level: Int) = replaceIndentByMargin(" ".repeat(level))
+
+ val cmpList = mutableListOf()
+
+ cmpList.add("""
+ |""".ind(2)
+ )
+
+ cmpList.add("""
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |""".ind(2)
+ )
+
+ cmpList.add("""
+ |
+ |
+ |
+ |
+ |
+ |""".ind(2)
+ )
+
+ cmpList.add("""
+ |
+ |
+ |
+ |
+ |
+ |""".ind(2)
+ )
+
+ cmpList.add("""
+ |""".ind(2)
+ )
+
+ cmpList.add("""
+ |""".ind(2)
+ )
+
+ // Shuffle the order of the components
+ cmpList.shuffle(RANDOM)
+ val (factoryPkg, factoryClass) = factoryClassDir.asFileTree.firstNotNullOf {
+ it.parentFile!!.name to it.name.removeSuffix(".java")
+ }
+ val (appPkg, appClass) = appClassDir.asFileTree.firstNotNullOf {
+ it.parentFile!!.name to it.name.removeSuffix(".java")
+ }
+ val components = cmpList.joinToString("\n\n")
+ .replace("\${applicationId}", applicationId.get())
+ val manifest = mergedManifest.asFile.get().readText().replace(Regex(".*\\ false
+ else -> true
+ }
+
+ fun List.process() = asSequence()
+ .filter(::notJavaKeyword)
+ // Distinct by lower case to support case insensitive file systems
+ .distinctBy { it.lowercase() }
+
+ val names = mutableListOf()
+ names.addAll(c1)
+ names.addAll(c2.process().take(30))
+ names.addAll(c3.process().take(30))
+ names.shuffle(RANDOM)
+
+ while (true) {
+ val cls = StringBuilder()
+ cls.append(names.random(kRANDOM))
+ cls.append('.')
+ cls.append(names.random(kRANDOM))
+ // Old Android does not support capitalized package names
+ // Check Android 7.0.0 PackageParser#buildClassName
+ yield(cls.toString().replaceFirstChar { it.lowercase() })
+ }
+ }.distinct().iterator()
+
+ fun genClass(type: String, outDir: File) {
+ val clzName = classNameGenerator.next()
+ val (pkg, name) = clzName.split('.')
+ val pkgDir = File(outDir, pkg)
+ pkgDir.mkdirs()
+ PrintStream(File(pkgDir, "$name.java")).use {
+ it.println("package $pkg;")
+ it.println("public class $name extends com.topjohnwu.magisk.$type {}")
+ }
+ }
+
+ genClass("DelegateComponentFactory", factoryOutDir)
+ genClass("StubApplication", appOutDir)
+}
+
+private fun genEncryptedResources(res: ByteArray, outDir: File) {
+ val mainPkgDir = File(outDir, "com/topjohnwu/magisk")
+ mainPkgDir.mkdirs()
+
+ // Generate iv and key
+ val iv = ByteArray(16)
+ val key = ByteArray(32)
+ RANDOM.nextBytes(iv)
+ RANDOM.nextBytes(key)
+
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
+ val bos = ByteArrayOutputStream()
+
+ ByteArrayInputStream(res).use {
+ CipherOutputStream(bos, cipher).use { os ->
+ it.transferTo(os)
+ }
+ }
+
+ PrintStream(File(mainPkgDir, "Bytes.java")).use {
+ it.println("package com.topjohnwu.magisk;")
+ it.println("public final class Bytes {")
+
+ it.byteField("key", key)
+ it.byteField("iv", iv)
+ it.byteField("res", bos.toByteArray())
+
+ it.println("}")
+ }
+}
+
+fun Project.setupStubApk() {
+ setupAppCommon()
+
+ androidComponents.onVariants { variant ->
+ val variantName = variant.name
+ val variantCapped = variantName.replaceFirstChar { it.uppercase() }
+ val manifestUpdater =
+ project.tasks.register("${variantName}ManifestProducer", ManifestUpdater::class.java) {
+ dependsOn("generate${variantCapped}ObfuscatedClass")
+ applicationId = variant.applicationId
+ appClassDir.set(layout.buildDirectory.dir("generated/source/app/$variantName"))
+ factoryClassDir.set(layout.buildDirectory.dir("generated/source/factory/$variantName"))
+ }
+ variant.artifacts.use(manifestUpdater)
+ .wiredWithFiles(
+ ManifestUpdater::mergedManifest,
+ ManifestUpdater::outputManifest)
+ .toTransform(SingleArtifact.MERGED_MANIFEST)
+ }
+
+ androidApp.applicationVariants.all {
+ val variantCapped = name.replaceFirstChar { it.uppercase() }
+ val variantLowered = name.lowercase()
+ val outFactoryClassDir = layout.buildDirectory.file("generated/source/factory/${variantLowered}").get().asFile
+ val outAppClassDir = layout.buildDirectory.file("generated/source/app/${variantLowered}").get().asFile
+ val outResDir = layout.buildDirectory.dir("generated/source/res/${variantLowered}").get().asFile
+ val aapt = File(androidApp.sdkDirectory, "build-tools/${androidApp.buildToolsVersion}/aapt2")
+ val apk = layout.buildDirectory.file("intermediates/linked_resources_binary_format/" +
+ "${variantLowered}/process${variantCapped}Resources/linked-resources-binary-format-${variantLowered}.ap_").get().asFile
+
+ val genManifestTask = tasks.register("generate${variantCapped}ObfuscatedClass") {
+ inputs.property("seed", RAND_SEED)
+ outputs.dirs(outFactoryClassDir, outAppClassDir)
+ doLast {
+ outFactoryClassDir.mkdirs()
+ outAppClassDir.mkdirs()
+ genStubClasses(outFactoryClassDir, outAppClassDir)
+ }
+ }
+ registerJavaGeneratingTask(genManifestTask, outFactoryClassDir, outAppClassDir)
+
+ val processResourcesTask = tasks.named("process${variantCapped}Resources") {
+ outputs.dir(outResDir)
+ doLast {
+ val apkTmp = File("${apk}.tmp")
+ providers.exec {
+ commandLine(aapt, "optimize", "-o", apkTmp, "--collapse-resource-names", apk)
+ }.result.get()
+
+ val bos = ByteArrayOutputStream()
+ ZipFile(apkTmp).use { src ->
+ ZipOutputStream(apk.outputStream()).use {
+ it.setLevel(Deflater.BEST_COMPRESSION)
+ it.putNextEntry(ZipEntry("AndroidManifest.xml"))
+ src.getInputStream(src.getEntry("AndroidManifest.xml")).transferTo(it)
+ it.closeEntry()
+ }
+ DeflaterOutputStream(bos, Deflater(Deflater.BEST_COMPRESSION)).use {
+ src.getInputStream(src.getEntry("resources.arsc")).transferTo(it)
+ }
+ }
+ apkTmp.delete()
+ genEncryptedResources(bos.toByteArray(), outResDir)
+ }
+ }
+
+ registerJavaGeneratingTask(processResourcesTask, outResDir)
+ }
+ // Override optimizeReleaseResources task
+ val apk = layout.buildDirectory.file("intermediates/linked_resources_binary_format/" +
+ "release/processReleaseResources/linked-resources-binary-format-release.ap_").get().asFile
+ val optRes = layout.buildDirectory.file("intermediates/optimized_processed_res/" +
+ "release/optimizeReleaseResources/resources-release-optimize.ap_").get().asFile
+ afterEvaluate {
+ tasks.named("optimizeReleaseResources") {
+ doLast { apk.copyTo(optRes, true) }
+ }
+ }
+ tasks.named("clean") {
+ delete.addAll(listOf("src/debug/AndroidManifest.xml", "src/release/AndroidManifest.xml"))
+ }
+}
diff --git a/app/core/.gitignore b/app/core/.gitignore
new file mode 100644
index 0000000..e2350c9
--- /dev/null
+++ b/app/core/.gitignore
@@ -0,0 +1,3 @@
+/build
+src/debug
+src/release
diff --git a/app/core/build.gradle.kts b/app/core/build.gradle.kts
new file mode 100644
index 0000000..4df7312
--- /dev/null
+++ b/app/core/build.gradle.kts
@@ -0,0 +1,72 @@
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("plugin.parcelize")
+ id("dev.zacsweers.moshix")
+ id("com.google.devtools.ksp")
+}
+
+setupCoreLib()
+
+ksp {
+ arg("room.generateKotlin", "true")
+}
+
+android {
+ namespace = "com.topjohnwu.magisk.core"
+
+ defaultConfig {
+ buildConfigField("String", "APP_PACKAGE_NAME", "\"com.topjohnwu.magisk\"")
+ buildConfigField("int", "APP_VERSION_CODE", "${Config.versionCode}")
+ buildConfigField("String", "APP_VERSION_NAME", "\"${Config.version}\"")
+ buildConfigField("int", "STUB_VERSION", Config.stubVersion)
+ consumerProguardFile("proguard-rules.pro")
+ }
+
+ buildFeatures {
+ aidl = true
+ buildConfig = true
+ }
+
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ }
+}
+
+dependencies {
+ api(project(":shared"))
+ coreLibraryDesugaring(libs.jdk.libs)
+
+ api(libs.timber)
+ api(libs.markwon.core)
+ implementation(libs.bcpkix)
+ implementation(libs.commons.compress)
+
+ api(libs.libsu.core)
+ api(libs.libsu.service)
+ api(libs.libsu.nio)
+
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.moshi)
+ implementation(libs.retrofit.scalars)
+
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging)
+ implementation(libs.okhttp.dnsoverhttps)
+
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+ ksp(libs.room.compiler)
+
+ implementation(libs.core.splashscreen)
+ implementation(libs.core.ktx)
+ implementation(libs.activity)
+ implementation(libs.collection.ktx)
+ implementation(libs.profileinstaller)
+
+ // We also implement all our tests in this module.
+ // However, we don't want to bundle test dependencies.
+ // That's why we make it compileOnly.
+ compileOnly(libs.test.junit)
+ compileOnly(libs.test.uiautomator)
+}
diff --git a/app/core/proguard-rules.pro b/app/core/proguard-rules.pro
new file mode 100644
index 0000000..854ec7b
--- /dev/null
+++ b/app/core/proguard-rules.pro
@@ -0,0 +1,41 @@
+# Parcelable
+-keepclassmembers class * implements android.os.Parcelable {
+ public static final ** CREATOR;
+}
+
+# Kotlin
+-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
+ public static void check*(...);
+ public static void throw*(...);
+}
+-assumenosideeffects class java.util.Objects {
+ public static ** requireNonNull(...);
+}
+-assumenosideeffects public class kotlin.coroutines.jvm.internal.DebugMetadataKt {
+ private static ** getDebugMetadataAnnotation(...) return null;
+}
+
+# Stub
+-keep class com.topjohnwu.magisk.core.App { (java.lang.Object); }
+-keepclassmembers class androidx.appcompat.app.AppCompatDelegateImpl {
+ boolean mActivityHandlesConfigFlagsChecked;
+ int mActivityHandlesConfigFlags;
+}
+
+# Strip Timber verbose and debug logging
+-assumenosideeffects class timber.log.Timber$Tree {
+ public void v(**);
+ public void d(**);
+}
+
+# With R8 full mode generic signatures are stripped for classes that are not
+# kept. Suspend functions are wrapped in continuations where the type argument
+# is used.
+-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
+
+# Excessive obfuscation
+-flattenpackagehierarchy
+-allowaccessmodification
+
+-dontwarn org.junit.**
+-dontwarn org.apache.**
diff --git a/app/core/src/main/AndroidManifest.xml b/app/core/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..35f2284
--- /dev/null
+++ b/app/core/src/main/AndroidManifest.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/core/src/main/aidl/com/topjohnwu/magisk/core/utils/IRootUtils.aidl b/app/core/src/main/aidl/com/topjohnwu/magisk/core/utils/IRootUtils.aidl
new file mode 100644
index 0000000..242e14b
--- /dev/null
+++ b/app/core/src/main/aidl/com/topjohnwu/magisk/core/utils/IRootUtils.aidl
@@ -0,0 +1,10 @@
+// IRootUtils.aidl
+package com.topjohnwu.magisk.core.utils;
+
+// Declare any non-default types here with import statements
+
+interface IRootUtils {
+ android.app.ActivityManager.RunningAppProcessInfo getAppProcess(int pid);
+ IBinder getFileSystem();
+ boolean addSystemlessHosts();
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/App.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/App.kt
new file mode 100644
index 0000000..28aa278
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/App.kt
@@ -0,0 +1,27 @@
+package com.topjohnwu.magisk.core
+
+import android.app.Application
+import android.content.Context
+import com.topjohnwu.magisk.StubApk
+import com.topjohnwu.magisk.core.utils.RootUtils
+
+open class App() : Application() {
+
+ constructor(o: Any) : this() {
+ val data = StubApk.Data(o)
+ // Add the root service name mapping
+ data.classToComponent[RootUtils::class.java.name] = data.rootService.name
+ // Send back the actual root service class
+ data.rootService = RootUtils::class.java
+ Info.stub = data
+ }
+
+ override fun attachBaseContext(context: Context) {
+ if (context is Application) {
+ AppContext.attachApplication(context)
+ } else {
+ super.attachBaseContext(context)
+ AppContext.attachApplication(this)
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/AppContext.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/AppContext.kt
new file mode 100644
index 0000000..52ea40b
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/AppContext.kt
@@ -0,0 +1,131 @@
+package com.topjohnwu.magisk.core
+
+import android.app.Activity
+import android.app.Application
+import android.app.LocaleManager
+import android.content.ComponentCallbacks2
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Configuration
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import android.os.Bundle
+import android.system.Os
+import androidx.profileinstaller.ProfileInstaller
+import com.topjohnwu.magisk.StubApk
+import com.topjohnwu.magisk.core.base.UntrackedActivity
+import com.topjohnwu.magisk.core.utils.LocaleSetting
+import com.topjohnwu.magisk.core.utils.NetworkObserver
+import com.topjohnwu.magisk.core.utils.RootUtils
+import com.topjohnwu.magisk.core.utils.ShellInit
+import com.topjohnwu.superuser.Shell
+import com.topjohnwu.superuser.internal.UiThreadHandler
+import com.topjohnwu.superuser.ipc.RootService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.lang.ref.WeakReference
+import kotlin.system.exitProcess
+
+lateinit var AppApkPath: String
+ private set
+
+object AppContext : ContextWrapper(null),
+ Application.ActivityLifecycleCallbacks, ComponentCallbacks2 {
+
+ val foregroundActivity: Activity? get() = ref.get()
+
+ private var ref = WeakReference(null)
+ private lateinit var application: Application
+ private lateinit var networkObserver: NetworkObserver
+
+ init {
+ // Always log full stack trace with Timber
+ Timber.plant(Timber.DebugTree())
+ Thread.setDefaultUncaughtExceptionHandler { _, e ->
+ Timber.e(e)
+ exitProcess(1)
+ }
+
+ Os.setenv("PATH", "${Os.getenv("PATH")}:/debug_ramdisk:/sbin", true)
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ LocaleSetting.instance.updateResource(resources)
+ }
+
+ override fun onActivityStarted(activity: Activity) {
+ networkObserver.postCurrentState()
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ if (activity is UntrackedActivity) return
+ ref = WeakReference(activity)
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ if (activity is UntrackedActivity) return
+ ref.clear()
+ }
+
+ override fun getApplicationContext() = application
+
+ fun attachApplication(app: Application) {
+ application = app
+ val base = app.baseContext
+ attachBaseContext(base)
+ app.registerActivityLifecycleCallbacks(this)
+ app.registerComponentCallbacks(this)
+
+ AppApkPath = if (isRunningAsStub) {
+ StubApk.current(base).path
+ } else {
+ base.packageResourcePath
+ }
+ resources.patch()
+
+ val shellBuilder = Shell.Builder.create()
+ .setFlags(Shell.FLAG_MOUNT_MASTER)
+ .setInitializers(ShellInit::class.java)
+ .setContext(this)
+ .setTimeout(2)
+ Shell.setDefaultBuilder(shellBuilder)
+ Shell.EXECUTOR = Dispatchers.IO.asExecutor()
+ RootUtils.bindTask = RootService.bindOrTask(
+ intent(),
+ UiThreadHandler.executor,
+ RootUtils.Connection
+ )
+ // Pre-heat the shell ASAP
+ Shell.getShell(null) {}
+
+ if (SDK_INT >= 34 && isRunningAsStub) {
+ // Send over the locale config manually
+ val lm = getSystemService(LocaleManager::class.java)
+ lm.overrideLocaleConfig = LocaleSetting.localeConfig
+ }
+ networkObserver = NetworkObserver.init(this)
+ if (!BuildConfig.DEBUG && !isRunningAsStub) {
+ GlobalScope.launch(Dispatchers.IO) {
+ ProfileInstaller.writeProfile(this@AppContext)
+ }
+ }
+ }
+
+ override fun createDeviceProtectedStorageContext(): Context {
+ return if (SDK_INT >= Build.VERSION_CODES.N) {
+ super.createDeviceProtectedStorageContext()
+ } else {
+ this
+ }
+ }
+
+ override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
+ override fun onActivityStopped(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+ override fun onLowMemory() {}
+ override fun onTrimMemory(level: Int) {}
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt
new file mode 100644
index 0000000..7fd4f85
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt
@@ -0,0 +1,209 @@
+package com.topjohnwu.magisk.core
+
+import android.os.Bundle
+import androidx.core.content.edit
+import com.topjohnwu.magisk.core.di.ServiceLocator
+import com.topjohnwu.magisk.core.repository.DBConfig
+import com.topjohnwu.magisk.core.repository.PreferenceConfig
+import com.topjohnwu.magisk.core.utils.LocaleSetting
+import kotlinx.coroutines.GlobalScope
+
+object Config : PreferenceConfig, DBConfig {
+
+ override val stringDB get() = ServiceLocator.stringDB
+ override val settingsDB get() = ServiceLocator.settingsDB
+ override val context get() = ServiceLocator.deContext
+ override val coroutineScope get() = GlobalScope
+
+ object Key {
+ // db configs
+ const val ROOT_ACCESS = "root_access"
+ const val SU_MULTIUSER_MODE = "multiuser_mode"
+ const val SU_MNT_NS = "mnt_ns"
+ const val SU_BIOMETRIC = "su_biometric"
+ const val ZYGISK = "zygisk"
+ const val BOOTLOOP = "bootloop"
+ const val SU_MANAGER = "requester"
+ const val KEYSTORE = "keystore"
+
+ // prefs
+ const val SU_REQUEST_TIMEOUT = "su_request_timeout"
+ const val SU_AUTO_RESPONSE = "su_auto_response"
+ const val SU_NOTIFICATION = "su_notification"
+ const val SU_REAUTH = "su_reauth"
+ const val SU_TAPJACK = "su_tapjack"
+ const val SU_RESTRICT = "su_restrict"
+ const val CHECK_UPDATES = "check_update"
+ const val RELEASE_CHANNEL = "release_channel"
+ const val CUSTOM_CHANNEL = "custom_channel"
+ const val LOCALE = "locale"
+ const val DARK_THEME = "dark_theme_extended"
+ const val DOWNLOAD_DIR = "download_dir"
+ const val SAFETY = "safety_notice"
+ const val THEME_ORDINAL = "theme_ordinal"
+ const val ASKED_HOME = "asked_home"
+ const val DOH = "doh"
+ const val RAND_NAME = "rand_name"
+
+ val NO_MIGRATION = setOf(ASKED_HOME, SU_REQUEST_TIMEOUT,
+ SU_AUTO_RESPONSE, SU_REAUTH, SU_TAPJACK)
+ }
+
+ object OldValue {
+ // Update channels
+ const val DEFAULT_CHANNEL = -1
+ const val STABLE_CHANNEL = 0
+ const val BETA_CHANNEL = 1
+ const val CUSTOM_CHANNEL = 2
+ const val CANARY_CHANNEL = 3
+ const val DEBUG_CHANNEL = 4
+ }
+
+ object Value {
+ // Update channels
+ const val DEFAULT_CHANNEL = -1
+ const val STABLE_CHANNEL = 0
+ const val BETA_CHANNEL = 1
+ const val DEBUG_CHANNEL = 2
+ const val CUSTOM_CHANNEL = 3
+
+ // root access mode
+ const val ROOT_ACCESS_DISABLED = 0
+ const val ROOT_ACCESS_APPS_ONLY = 1
+ const val ROOT_ACCESS_ADB_ONLY = 2
+ const val ROOT_ACCESS_APPS_AND_ADB = 3
+
+ // su multiuser
+ const val MULTIUSER_MODE_OWNER_ONLY = 0
+ const val MULTIUSER_MODE_OWNER_MANAGED = 1
+ const val MULTIUSER_MODE_USER = 2
+
+ // su mnt ns
+ const val NAMESPACE_MODE_GLOBAL = 0
+ const val NAMESPACE_MODE_REQUESTER = 1
+ const val NAMESPACE_MODE_ISOLATE = 2
+
+ // su notification
+ const val NO_NOTIFICATION = 0
+ const val NOTIFICATION_TOAST = 1
+
+ // su auto response
+ const val SU_PROMPT = 0
+ const val SU_AUTO_DENY = 1
+ const val SU_AUTO_ALLOW = 2
+
+ // su timeout
+ val TIMEOUT_LIST = longArrayOf(0, -1, 10, 20, 30, 60)
+ }
+
+ @JvmField var keepVerity = false
+ @JvmField var keepEnc = false
+ @JvmField var recovery = false
+ var denyList = false
+
+ var askedHome by preference(Key.ASKED_HOME, false)
+ var bootloop by dbSettings(Key.BOOTLOOP, 0)
+
+ var safetyNotice by preference(Key.SAFETY, true)
+ var darkTheme by preference(Key.DARK_THEME, -1)
+ var themeOrdinal by preference(Key.THEME_ORDINAL, 0)
+
+ private var checkUpdatePrefs by preference(Key.CHECK_UPDATES, true)
+ private var localePrefs by preference(Key.LOCALE, "")
+ var doh by preference(Key.DOH, false)
+ var updateChannel by preference(Key.RELEASE_CHANNEL, Value.DEFAULT_CHANNEL)
+ var customChannelUrl by preference(Key.CUSTOM_CHANNEL, "")
+ var downloadDir by preference(Key.DOWNLOAD_DIR, "")
+ var randName by preference(Key.RAND_NAME, true)
+ var checkUpdate
+ get() = checkUpdatePrefs
+ set(value) {
+ if (checkUpdatePrefs != value) {
+ checkUpdatePrefs = value
+ JobService.schedule(AppContext)
+ }
+ }
+ var locale
+ get() = localePrefs
+ set(value) {
+ localePrefs = value
+ LocaleSetting.instance.setLocale(value)
+ }
+
+ var zygisk by dbSettings(Key.ZYGISK, Info.isEmulator)
+ var suManager by dbStrings(Key.SU_MANAGER, "", true)
+ var keyStoreRaw by dbStrings(Key.KEYSTORE, "", true)
+
+ var suDefaultTimeout by preferenceStrInt(Key.SU_REQUEST_TIMEOUT, 10)
+ var suAutoResponse by preferenceStrInt(Key.SU_AUTO_RESPONSE, Value.SU_PROMPT)
+ var suNotification by preferenceStrInt(Key.SU_NOTIFICATION, Value.NOTIFICATION_TOAST)
+ var rootMode by dbSettings(Key.ROOT_ACCESS, Value.ROOT_ACCESS_APPS_AND_ADB)
+ var suMntNamespaceMode by dbSettings(Key.SU_MNT_NS, Value.NAMESPACE_MODE_REQUESTER)
+ var suMultiuserMode by dbSettings(Key.SU_MULTIUSER_MODE, Value.MULTIUSER_MODE_OWNER_ONLY)
+ private var suBiometric by dbSettings(Key.SU_BIOMETRIC, false)
+ var suAuth
+ get() = Info.isDeviceSecure && suBiometric
+ set(value) {
+ suBiometric = value
+ }
+ var suReAuth by preference(Key.SU_REAUTH, false)
+ var suTapjack by preference(Key.SU_TAPJACK, true)
+ var suRestrict by preference(Key.SU_RESTRICT, false)
+
+ private const val SU_FINGERPRINT = "su_fingerprint"
+ private const val UPDATE_CHANNEL = "update_channel"
+
+ fun toBundle(): Bundle {
+ val map = prefs.all - Key.NO_MIGRATION
+ return Bundle().apply {
+ for ((key, value) in map) {
+ when (value) {
+ is String -> putString(key, value)
+ is Int -> putInt(key, value)
+ is Boolean -> putBoolean(key, value)
+ }
+ }
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun fromBundle(bundle: Bundle) {
+ val keys = bundle.keySet().apply { removeAll(Key.NO_MIGRATION) }
+ prefs.edit {
+ for (key in keys) {
+ when (val value = bundle.get(key)) {
+ is String -> putString(key, value)
+ is Int -> putInt(key, value)
+ is Boolean -> putBoolean(key, value)
+ }
+ }
+ }
+ }
+
+ fun init(bundle: Bundle?) {
+ // Only try to load prefs when fresh install
+ if (bundle != null && prefs.all.isEmpty()) {
+ fromBundle(bundle)
+ }
+
+ prefs.edit {
+ // Migrate su_fingerprint
+ if (prefs.getBoolean(SU_FINGERPRINT, false))
+ suBiometric = true
+ remove(SU_FINGERPRINT)
+
+ // Migrate update_channel
+ prefs.getString(UPDATE_CHANNEL, null)?.let {
+ val channel = when (it.toInt()) {
+ OldValue.STABLE_CHANNEL -> Value.STABLE_CHANNEL
+ OldValue.CANARY_CHANNEL, OldValue.BETA_CHANNEL -> Value.BETA_CHANNEL
+ OldValue.DEBUG_CHANNEL -> Value.DEBUG_CHANNEL
+ OldValue.CUSTOM_CHANNEL -> Value.CUSTOM_CHANNEL
+ else -> Value.DEFAULT_CHANNEL
+ }
+ putInt(Key.RELEASE_CHANNEL, channel)
+ }
+ remove(UPDATE_CHANNEL)
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Const.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Const.kt
new file mode 100644
index 0000000..e6f432d
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Const.kt
@@ -0,0 +1,70 @@
+package com.topjohnwu.magisk.core
+
+import android.os.Build
+import android.os.Process
+import com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_CODE
+
+@Suppress("DEPRECATION")
+object Const {
+
+ val CPU_ABI: String get() = Build.SUPPORTED_ABIS[0]
+
+ // Null if 32-bit only or 64-bit only
+ val CPU_ABI_32 =
+ if (Build.SUPPORTED_64_BIT_ABIS.isEmpty()) null
+ else Build.SUPPORTED_32_BIT_ABIS.firstOrNull()
+
+ // Paths
+ const val MODULE_PATH = "/data/adb/modules"
+ const val TMPDIR = "/dev/tmp"
+ const val MAGISK_LOG = "/cache/magisk.log"
+
+ // Misc
+ val USER_ID = Process.myUid() / 100000
+
+ object Version {
+ const val MIN_VERSION = "v22.0"
+ const val MIN_VERCODE = 22000
+
+ private fun isCanary() = (Info.env.versionCode % 100) != 0
+ fun atLeast_24_0() = Info.env.versionCode >= 24000 || isCanary()
+ fun atLeast_25_0() = Info.env.versionCode >= 25000 || isCanary()
+ fun atLeast_28_0() = Info.env.versionCode >= 28000 || isCanary()
+ fun atLeast_30_1() = Info.env.versionCode >= 30100 || isCanary()
+ }
+
+ object ID {
+ const val DOWNLOAD_JOB_ID = 6
+ const val CHECK_UPDATE_JOB_ID = 7
+ }
+
+ object Url {
+ const val PATREON_URL = "https://www.patreon.com/topjohnwu"
+ const val SOURCE_CODE_URL = "https://github.com/topjohnwu/Magisk"
+
+ const val GITHUB_API_URL = "https://api.github.com/"
+ const val GITHUB_PAGE_URL = "https://topjohnwu.github.io/magisk-files/"
+ const val INVALID_URL = "https://example.com/"
+ }
+
+ object Key {
+ // intents
+ const val OPEN_SECTION = "section"
+ const val PREV_CONFIG = "prev_config"
+ }
+
+ object Value {
+ const val FLASH_ZIP = "flash"
+ const val PATCH_FILE = "patch"
+ const val FLASH_MAGISK = "magisk"
+ const val FLASH_INACTIVE_SLOT = "slot"
+ const val UNINSTALL = "uninstall"
+ }
+
+ object Nav {
+ const val HOME = "home"
+ const val SETTINGS = "settings"
+ const val MODULES = "modules"
+ const val SUPERUSER = "superuser"
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Hacks.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Hacks.kt
new file mode 100644
index 0000000..8abc741
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Hacks.kt
@@ -0,0 +1,55 @@
+@file:Suppress("DEPRECATION")
+
+package com.topjohnwu.magisk.core
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.res.Configuration
+import android.content.res.Resources
+import com.topjohnwu.magisk.StubApk
+import com.topjohnwu.magisk.core.ktx.unwrap
+import com.topjohnwu.magisk.core.utils.LocaleSetting
+
+fun Resources.addAssetPath(path: String) = StubApk.addAssetPath(this, path)
+
+fun Resources.patch(): Resources {
+ if (isRunningAsStub)
+ addAssetPath(AppApkPath)
+ LocaleSetting.instance.updateResource(this)
+ return this
+}
+
+fun Context.patch(): Context {
+ unwrap().resources.patch()
+ return this
+}
+
+// Wrapping is only necessary for ContextThemeWrapper to support configuration overrides
+fun Context.wrap(): Context {
+ patch()
+ return object : ContextWrapper(this) {
+ override fun createConfigurationContext(config: Configuration): Context {
+ return super.createConfigurationContext(config).wrap()
+ }
+ }
+}
+
+fun Class<*>.cmp(pkg: String) =
+ ComponentName(pkg, Info.stub?.classToComponent?.get(name) ?: name)
+
+inline fun Context.intent() = Intent().setComponent(T::class.java.cmp(packageName))
+
+// Keep a reference to these resources to prevent it from
+// being removed when running "remove unused resources"
+val shouldKeepResources = listOf(
+ R.string.no_info_provided,
+ R.string.release_notes,
+ R.string.invalid_update_channel,
+ R.string.update_available,
+ R.string.app_changelog,
+ R.string.home_item_source,
+ R.drawable.ic_more,
+ R.array.allow_timeout,
+)
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Info.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Info.kt
new file mode 100644
index 0000000..8aa8df5
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Info.kt
@@ -0,0 +1,125 @@
+package com.topjohnwu.magisk.core
+
+import android.app.KeyguardManager
+import android.os.Build
+import androidx.lifecycle.MutableLiveData
+import com.topjohnwu.magisk.StubApk
+import com.topjohnwu.magisk.core.ktx.getProperty
+import com.topjohnwu.magisk.core.model.UpdateInfo
+import com.topjohnwu.magisk.core.repository.NetworkService
+import com.topjohnwu.superuser.CallbackList
+import com.topjohnwu.superuser.Shell
+import com.topjohnwu.superuser.ShellUtils.fastCmd
+import com.topjohnwu.superuser.ShellUtils.fastCmdResult
+import kotlinx.coroutines.Runnable
+
+val isRunningAsStub get() = Info.stub != null
+
+object Info {
+
+ var stub: StubApk.Data? = null
+
+ private val EMPTY_UPDATE = UpdateInfo()
+ var update = EMPTY_UPDATE
+ private set
+
+ suspend fun fetchUpdate(svc: NetworkService): UpdateInfo? {
+ return if (update === EMPTY_UPDATE) {
+ svc.fetchUpdate()?.apply { update = this }
+ } else update
+ }
+
+ fun resetUpdate() {
+ update = EMPTY_UPDATE
+ }
+
+ var isRooted = false
+ var noDataExec = false
+ var patchBootVbmeta = false
+
+ @JvmStatic var env = Env()
+ private set
+ @JvmStatic var isSAR = false
+ private set
+ var legacySAR = false
+ private set
+ var isAB = false
+ private set
+ var slot = ""
+ private set
+ var isVendorBoot = false
+ private set
+ @JvmField val isZygiskEnabled = System.getenv("ZYGISK_ENABLED") == "1"
+ @JvmStatic val isFDE get() = crypto == "block"
+ @JvmStatic var ramdisk = false
+ private set
+ private var crypto = ""
+
+ val isEmulator =
+ Build.DEVICE.contains("vsoc")
+ || getProperty("ro.kernel.qemu", "0") == "1"
+ || getProperty("ro.boot.qemu", "0") == "1"
+
+ val isConnected = MutableLiveData(false)
+
+ val showSuperUser: Boolean get() {
+ return env.isActive && (Const.USER_ID == 0
+ || Config.suMultiuserMode == Config.Value.MULTIUSER_MODE_USER)
+ }
+
+ val isDeviceSecure get() =
+ AppContext.getSystemService(KeyguardManager::class.java).isDeviceSecure
+
+ class Env(
+ val versionString: String = "",
+ val isDebug: Boolean = false,
+ code: Int = -1
+ ) {
+ val versionCode = when {
+ code < Const.Version.MIN_VERCODE -> -1
+ isRooted -> code
+ else -> -1
+ }
+ val isUnsupported = code > 0 && code < Const.Version.MIN_VERCODE
+ val isActive = versionCode > 0
+ }
+
+ fun init(shell: Shell) {
+ if (shell.isRoot) {
+ val v = fastCmd(shell, "magisk -v").split(":")
+ env = Env(
+ v[0], v.size >= 3 && v[2] == "D",
+ runCatching { fastCmd("magisk -V").toInt() }.getOrDefault(-1)
+ )
+ Config.denyList = fastCmdResult(shell, "magisk --denylist status")
+ }
+
+ val map = mutableMapOf()
+ val list = object : CallbackList(Runnable::run) {
+ override fun onAddElement(e: String) {
+ val split = e.split("=")
+ if (split.size >= 2) {
+ map[split[0]] = split[1]
+ }
+ }
+ }
+ shell.newJob().add("(app_init)").to(list).exec()
+
+ fun getVar(name: String) = map[name] ?: ""
+ fun getBool(name: String) = map[name].toBoolean()
+
+ isSAR = getBool("SYSTEM_AS_ROOT")
+ ramdisk = getBool("RAMDISKEXIST")
+ isAB = getBool("ISAB")
+ patchBootVbmeta = getBool("PATCHVBMETAFLAG")
+ crypto = getVar("CRYPTOTYPE")
+ slot = getVar("SLOT")
+ legacySAR = getBool("LEGACYSAR")
+ isVendorBoot = getBool("VENDORBOOT")
+
+ // Default presets
+ Config.recovery = getBool("RECOVERYMODE")
+ Config.keepVerity = getBool("KEEPVERITY")
+ Config.keepEnc = getBool("KEEPFORCEENCRYPT")
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/JobService.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/JobService.kt
new file mode 100644
index 0000000..7019f27
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/JobService.kt
@@ -0,0 +1,103 @@
+package com.topjohnwu.magisk.core
+
+import android.annotation.SuppressLint
+import android.annotation.TargetApi
+import android.app.Notification
+import android.app.job.JobInfo
+import android.app.job.JobParameters
+import android.app.job.JobScheduler
+import android.content.Context
+import androidx.core.content.getSystemService
+import com.topjohnwu.magisk.core.base.BaseJobService
+import com.topjohnwu.magisk.core.di.ServiceLocator
+import com.topjohnwu.magisk.core.download.DownloadEngine
+import com.topjohnwu.magisk.core.download.DownloadSession
+import com.topjohnwu.magisk.core.download.Subject
+import com.topjohnwu.magisk.view.Notifications
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import java.util.concurrent.TimeUnit
+
+class JobService : BaseJobService() {
+
+ private var mSession: Session? = null
+
+ @TargetApi(value = 34)
+ inner class Session(
+ private var params: JobParameters
+ ) : DownloadSession {
+
+ override val context get() = this@JobService
+ val engine = DownloadEngine(this)
+
+ fun updateParams(params: JobParameters) {
+ this.params = params
+ engine.reattach()
+ }
+
+ override fun attachNotification(id: Int, builder: Notification.Builder) {
+ setNotification(params, id, builder.build(), JOB_END_NOTIFICATION_POLICY_REMOVE)
+ }
+
+ override fun onDownloadComplete() {
+ jobFinished(params, false)
+ }
+ }
+
+ @SuppressLint("NewApi")
+ override fun onStartJob(params: JobParameters): Boolean {
+ return when (params.jobId) {
+ Const.ID.CHECK_UPDATE_JOB_ID -> checkUpdate(params)
+ Const.ID.DOWNLOAD_JOB_ID -> downloadFile(params)
+ else -> false
+ }
+ }
+
+ override fun onStopJob(params: JobParameters?) = false
+
+ @TargetApi(value = 34)
+ private fun downloadFile(params: JobParameters): Boolean {
+ params.transientExtras.classLoader = Subject::class.java.classLoader
+ val subject = params.transientExtras
+ .getParcelable(DownloadEngine.SUBJECT_KEY, Subject::class.java) ?:
+ return false
+
+ val session = mSession?.also {
+ it.updateParams(params)
+ } ?: run {
+ Session(params).also { mSession = it }
+ }
+
+ session.engine.download(subject)
+ return true
+ }
+
+ private fun checkUpdate(params: JobParameters): Boolean {
+ GlobalScope.launch(Dispatchers.IO) {
+ Info.fetchUpdate(ServiceLocator.networkService)?.let {
+ if (Info.env.isActive && BuildConfig.APP_VERSION_CODE < it.versionCode)
+ Notifications.updateAvailable()
+ jobFinished(params, false)
+ }
+ }
+ return true
+ }
+
+ companion object {
+ fun schedule(context: Context) {
+ val scheduler = context.getSystemService() ?: return
+ if (Config.checkUpdate) {
+ val cmp = JobService::class.java.cmp(context.packageName)
+ val info = JobInfo.Builder(Const.ID.CHECK_UPDATE_JOB_ID, cmp)
+ .setPeriodic(TimeUnit.HOURS.toMillis(12))
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .setRequiresDeviceIdle(true)
+ .build()
+ scheduler.schedule(info)
+ } else {
+ scheduler.cancel(Const.ID.CHECK_UPDATE_JOB_ID)
+ }
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Provider.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Provider.kt
new file mode 100644
index 0000000..6007d7f
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Provider.kt
@@ -0,0 +1,18 @@
+package com.topjohnwu.magisk.core
+
+import android.os.Bundle
+import com.topjohnwu.magisk.core.base.BaseProvider
+import com.topjohnwu.magisk.core.su.SuCallbackHandler
+
+class Provider : BaseProvider() {
+
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
+ return when (method) {
+ SuCallbackHandler.LOG, SuCallbackHandler.NOTIFY -> {
+ SuCallbackHandler.run(context!!, method, extras)
+ Bundle.EMPTY
+ }
+ else -> Bundle.EMPTY
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Receiver.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Receiver.kt
new file mode 100644
index 0000000..635e384
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Receiver.kt
@@ -0,0 +1,68 @@
+package com.topjohnwu.magisk.core
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.IntentCompat
+import com.topjohnwu.magisk.core.base.BaseReceiver
+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.view.Notifications
+import com.topjohnwu.magisk.view.Shortcuts
+import com.topjohnwu.superuser.Shell
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+open class Receiver : BaseReceiver() {
+
+ private val policyDB get() = ServiceLocator.policyDB
+
+ @SuppressLint("InlinedApi")
+ private fun getPkg(intent: Intent): String? {
+ val pkg = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)
+ return pkg ?: intent.data?.schemeSpecificPart
+ }
+
+ private fun getUid(intent: Intent): Int? {
+ val uid = intent.getIntExtra(Intent.EXTRA_UID, -1)
+ return if (uid == -1) null else uid
+ }
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ intent ?: return
+ super.onReceive(context, intent)
+
+ fun rmPolicy(uid: Int) = GlobalScope.launch {
+ policyDB.delete(uid)
+ }
+
+ when (intent.action ?: return) {
+ DownloadEngine.ACTION -> {
+ IntentCompat.getParcelableExtra(
+ intent, DownloadEngine.SUBJECT_KEY, Subject::class.java)?.let {
+ DownloadEngine.start(context, it)
+ }
+ }
+ Intent.ACTION_PACKAGE_REPLACED -> {
+ // This will only work pre-O
+ if (Config.suReAuth)
+ getUid(intent)?.let { rmPolicy(it) }
+ }
+ Intent.ACTION_UID_REMOVED -> {
+ getUid(intent)?.let { rmPolicy(it) }
+ }
+ Intent.ACTION_PACKAGE_FULLY_REMOVED -> {
+ getPkg(intent)?.let { Shell.cmd("magisk --denylist rm $it").submit() }
+ }
+ Intent.ACTION_LOCALE_CHANGED -> Shortcuts.setupDynamic(context)
+ Intent.ACTION_MY_PACKAGE_REPLACED -> {
+ @Suppress("DEPRECATION")
+ val installer = context.packageManager.getInstallerPackageName(context.packageName)
+ if (installer == context.packageName) {
+ Notifications.updateDone()
+ }
+ }
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Service.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Service.kt
new file mode 100644
index 0000000..c567b0e
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Service.kt
@@ -0,0 +1,39 @@
+package com.topjohnwu.magisk.core
+
+import android.app.Notification
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.ServiceCompat
+import androidx.core.content.IntentCompat
+import com.topjohnwu.magisk.core.base.BaseService
+import com.topjohnwu.magisk.core.download.DownloadEngine
+import com.topjohnwu.magisk.core.download.DownloadSession
+import com.topjohnwu.magisk.core.download.Subject
+
+class Service : BaseService(), DownloadSession {
+
+ private var mEngine: DownloadEngine? = null
+ override val context get() = this
+
+ override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ if (intent.action == DownloadEngine.ACTION) {
+ IntentCompat
+ .getParcelableExtra(intent, DownloadEngine.SUBJECT_KEY, Subject::class.java)
+ ?.let { subject ->
+ val engine = mEngine ?: DownloadEngine(this).also { mEngine = it }
+ engine.download(subject)
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun attachNotification(id: Int, builder: Notification.Builder) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
+ startForeground(id, builder.build())
+ }
+
+ override fun onDownloadComplete() {
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt
new file mode 100644
index 0000000..7bd9e44
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt
@@ -0,0 +1,139 @@
+package com.topjohnwu.magisk.core.base
+
+import android.Manifest.permission.POST_NOTIFICATIONS
+import android.Manifest.permission.REQUEST_INSTALL_PACKAGES
+import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultCallback
+import androidx.activity.result.contract.ActivityResultContracts.GetContent
+import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
+import com.topjohnwu.magisk.core.R
+import com.topjohnwu.magisk.core.ktx.reflectField
+import com.topjohnwu.magisk.core.ktx.toast
+import com.topjohnwu.magisk.core.utils.RequestAuthentication
+import com.topjohnwu.magisk.core.utils.RequestInstall
+
+interface ContentResultCallback: ActivityResultCallback, Parcelable {
+ fun onActivityLaunch() {}
+ // Make the result type explicitly non-null
+ override fun onActivityResult(result: Uri)
+}
+
+interface UntrackedActivity
+
+interface IActivityExtension {
+ val extension: ActivityExtension
+ fun withPermission(permission: String, callback: (Boolean) -> Unit) {
+ extension.withPermission(permission, callback)
+ }
+ fun withAuthentication(callback: (Boolean) -> Unit) {
+ extension.withAuthentication(callback)
+ }
+ fun getContent(type: String, callback: ContentResultCallback) {
+ extension.getContent(type, callback)
+ }
+}
+
+class ActivityExtension(private val activity: ComponentActivity) {
+
+ private var permissionCallback: ((Boolean) -> Unit)? = null
+ private val requestPermission = activity.registerForActivityResult(RequestPermission()) {
+ permissionCallback?.invoke(it)
+ permissionCallback = null
+ }
+
+ private var installCallback: ((Boolean) -> Unit)? = null
+ private val requestInstall = activity.registerForActivityResult(RequestInstall()) {
+ installCallback?.invoke(it)
+ installCallback = null
+ }
+
+ private var authenticateCallback: ((Boolean) -> Unit)? = null
+ private val requestAuthenticate = activity.registerForActivityResult(RequestAuthentication()) {
+ authenticateCallback?.invoke(it)
+ authenticateCallback = null
+ }
+
+ private var contentCallback: ContentResultCallback? = null
+ private val getContent = activity.registerForActivityResult(GetContent()) {
+ if (it != null) contentCallback?.onActivityResult(it)
+ contentCallback = null
+ }
+
+ fun onCreate(savedInstanceState: Bundle?) {
+ contentCallback = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ savedInstanceState?.getParcelable(CONTENT_CALLBACK_KEY)
+ } else {
+ savedInstanceState
+ ?.getParcelable(CONTENT_CALLBACK_KEY, ContentResultCallback::class.java)
+ }
+ }
+
+ fun onSaveInstanceState(outState: Bundle) {
+ contentCallback?.let {
+ outState.putParcelable(CONTENT_CALLBACK_KEY, it)
+ }
+ }
+
+ fun withPermission(permission: String, callback: (Boolean) -> Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
+ permission == WRITE_EXTERNAL_STORAGE) {
+ // We do not need external rw on R+
+ callback(true)
+ return
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU &&
+ permission == POST_NOTIFICATIONS) {
+ // All apps have notification permissions before T
+ callback(true)
+ return
+ }
+ if (permission == REQUEST_INSTALL_PACKAGES) {
+ installCallback = callback
+ requestInstall.launch(Unit)
+ } else {
+ permissionCallback = callback
+ requestPermission.launch(permission)
+ }
+ }
+
+ fun withAuthentication(callback: (Boolean) -> Unit) {
+ authenticateCallback = callback
+ requestAuthenticate.launch(Unit)
+ }
+
+ fun getContent(type: String, callback: ContentResultCallback) {
+ contentCallback = callback
+ try {
+ getContent.launch(type)
+ callback.onActivityLaunch()
+ } catch (e: ActivityNotFoundException) {
+ activity.toast(R.string.app_not_found, Toast.LENGTH_SHORT)
+ }
+ }
+
+ companion object {
+ private const val CONTENT_CALLBACK_KEY = "content_callback"
+ }
+}
+
+val Activity.launchPackage: String? get() {
+ return if (Build.VERSION.SDK_INT >= 34) {
+ launchedFromPackage
+ } else {
+ Activity::class.java.reflectField("mReferrer").get(this) as String?
+ }
+}
+
+fun Activity.relaunch() {
+ startActivity(Intent(intent).setFlags(0))
+ finish()
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseJobService.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseJobService.kt
new file mode 100644
index 0000000..b178f6c
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseJobService.kt
@@ -0,0 +1,11 @@
+package com.topjohnwu.magisk.core.base
+
+import android.app.job.JobService
+import android.content.Context
+import com.topjohnwu.magisk.core.patch
+
+abstract class BaseJobService : JobService() {
+ override fun attachBaseContext(base: Context) {
+ super.attachBaseContext(base.patch())
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseProvider.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseProvider.kt
new file mode 100644
index 0000000..8295bd0
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseProvider.kt
@@ -0,0 +1,21 @@
+package com.topjohnwu.magisk.core.base
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.ProviderInfo
+import android.database.Cursor
+import android.net.Uri
+import com.topjohnwu.magisk.core.patch
+
+open class BaseProvider : ContentProvider() {
+ override fun attachInfo(context: Context, info: ProviderInfo) {
+ super.attachInfo(context.patch(), info)
+ }
+ override fun onCreate() = true
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseReceiver.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseReceiver.kt
new file mode 100644
index 0000000..e29a991
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseReceiver.kt
@@ -0,0 +1,14 @@
+package com.topjohnwu.magisk.core.base
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.CallSuper
+import com.topjohnwu.magisk.core.patch
+
+abstract class BaseReceiver : BroadcastReceiver() {
+ @CallSuper
+ override fun onReceive(context: Context, intent: Intent?) {
+ context.patch()
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseService.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseService.kt
new file mode 100644
index 0000000..bc1e741
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/base/BaseService.kt
@@ -0,0 +1,14 @@
+package com.topjohnwu.magisk.core.base
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import com.topjohnwu.magisk.core.patch
+
+open class BaseService : Service() {
+ override fun attachBaseContext(base: Context) {
+ super.attachBaseContext(base.patch())
+ }
+ override fun onBind(intent: Intent?): IBinder? = null
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/base/SplashScreen.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/base/SplashScreen.kt
new file mode 100644
index 0000000..8f604b8
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/base/SplashScreen.kt
@@ -0,0 +1,161 @@
+package com.topjohnwu.magisk.core.base
+
+import android.Manifest.permission.REQUEST_INSTALL_PACKAGES
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.topjohnwu.magisk.StubApk
+import com.topjohnwu.magisk.core.BuildConfig
+import com.topjohnwu.magisk.core.BuildConfig.APP_PACKAGE_NAME
+import com.topjohnwu.magisk.core.Config
+import com.topjohnwu.magisk.core.Const
+import com.topjohnwu.magisk.core.Info
+import com.topjohnwu.magisk.core.JobService
+import com.topjohnwu.magisk.core.R
+import com.topjohnwu.magisk.core.di.ServiceLocator
+import com.topjohnwu.magisk.core.isRunningAsStub
+import com.topjohnwu.magisk.core.ktx.writeTo
+import com.topjohnwu.magisk.core.tasks.AppMigration
+import com.topjohnwu.magisk.core.utils.RootUtils
+import com.topjohnwu.magisk.view.Notifications
+import com.topjohnwu.magisk.view.Shortcuts
+import com.topjohnwu.superuser.Shell
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+
+interface SplashScreenHost : IActivityExtension {
+ val splashController: SplashController<*>
+
+ fun onCreateUi(savedInstanceState: Bundle?)
+ fun showInvalidStateMessage()
+}
+
+class SplashController(private val activity: T)
+ where T : ComponentActivity, T: SplashScreenHost {
+
+ companion object {
+ private var splashShown = false
+ }
+
+ private var shouldCreateUiOnResume = false
+
+ fun preOnCreate() {
+ if (isRunningAsStub && !splashShown) {
+ // Manually apply splash theme for stub
+ activity.theme.applyStyle(R.style.StubSplashTheme, true)
+ }
+ }
+
+ fun onCreate(savedInstanceState: Bundle?) {
+ if (!isRunningAsStub) {
+ val splashScreen = activity.installSplashScreen()
+ splashScreen.setKeepOnScreenCondition { !splashShown }
+ }
+
+ if (splashShown) {
+ doCreateUi(savedInstanceState)
+ } else {
+ Shell.getShell(Shell.EXECUTOR) {
+ if (isRunningAsStub && !it.isRoot) {
+ activity.showInvalidStateMessage()
+ return@getShell
+ }
+ activity.initializeApp()
+ activity.runOnUiThread {
+ splashShown = true
+ if (isRunningAsStub) {
+ // Re-launch main activity without splash theme
+ activity.relaunch()
+ } else {
+ if (activity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ doCreateUi(savedInstanceState)
+ } else {
+ shouldCreateUiOnResume = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun onResume() {
+ if (shouldCreateUiOnResume) {
+ doCreateUi(null)
+ }
+ }
+
+ private fun doCreateUi(savedInstanceState: Bundle?) {
+ shouldCreateUiOnResume = false
+ activity.onCreateUi(savedInstanceState)
+ }
+
+ private fun T.initializeApp() {
+ val prevPkg = launchPackage
+ val prevConfig = intent.getBundleExtra(Const.Key.PREV_CONFIG)
+ val isPackageMigration = prevPkg != null && prevConfig != null
+
+ Config.init(prevConfig)
+
+ if (packageName != APP_PACKAGE_NAME) {
+ runCatching {
+ // Hidden, remove com.topjohnwu.magisk if exist as it could be malware
+ packageManager.getApplicationInfo(APP_PACKAGE_NAME, 0)
+ Shell.cmd("(pm uninstall $APP_PACKAGE_NAME)& >/dev/null 2>&1").exec()
+ }
+ } else {
+ if (Config.suManager.isNotEmpty()) {
+ Config.suManager = ""
+ }
+ if (isPackageMigration) {
+ Shell.cmd("(pm uninstall $prevPkg)& >/dev/null 2>&1").exec()
+ }
+ }
+
+ if (isPackageMigration) {
+ runOnUiThread {
+ // Relaunch the process after package migration
+ StubApk.restartProcess(this)
+ }
+ return
+ }
+
+ // Validate stub APK
+ if (isRunningAsStub && (
+ // Version mismatch
+ Info.stub!!.version != BuildConfig.STUB_VERSION ||
+ // Not properly patched
+ intent.component!!.className.contains(AppMigration.PLACEHOLDER))
+ ) {
+ withPermission(REQUEST_INSTALL_PACKAGES) { granted ->
+ if (granted) {
+ lifecycleScope.launch {
+ val apk = File(cacheDir, "stub.apk")
+ try {
+ assets.open("stub.apk").writeTo(apk)
+ AppMigration.upgradeStub(activity, apk)?.let {
+ startActivity(it)
+ }
+ } catch (e: IOException) {
+ Timber.e(e)
+ }
+ }
+ }
+ }
+ return
+ }
+
+ Notifications.setup()
+ JobService.schedule(this)
+ Shortcuts.setupDynamic(this)
+
+ // Pre-fetch network services
+ ServiceLocator.networkService
+
+ // Wait for root service
+ RootUtils.Connection.await()
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/data/RetrofitInterfaces.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/data/RetrofitInterfaces.kt
new file mode 100644
index 0000000..beabc58
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/data/RetrofitInterfaces.kt
@@ -0,0 +1,48 @@
+package com.topjohnwu.magisk.core.data
+
+import com.topjohnwu.magisk.core.model.ModuleJson
+import com.topjohnwu.magisk.core.model.Release
+import com.topjohnwu.magisk.core.model.UpdateJson
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.Path
+import retrofit2.http.Query
+import retrofit2.http.Streaming
+import retrofit2.http.Url
+
+interface RawUrl {
+
+ @GET
+ @Streaming
+ suspend fun fetchFile(@Url url: String): ResponseBody
+
+ @GET
+ suspend fun fetchString(@Url url: String): String
+
+ @GET
+ suspend fun fetchModuleJson(@Url url: String): ModuleJson
+
+ @GET
+ suspend fun fetchUpdateJson(@Url url: String): UpdateJson
+}
+
+interface GithubApiServices {
+
+ @GET("/repos/{owner}/{repo}/releases")
+ @Headers("Accept: application/vnd.github+json")
+ suspend fun fetchReleases(
+ @Path("owner") owner: String = "topjohnwu",
+ @Path("repo") repo: String = "Magisk",
+ @Query("per_page") per: Int = 10,
+ @Query("page") page: Int = 1,
+ ): Response>
+
+ @GET("/repos/{owner}/{repo}/releases/latest")
+ @Headers("Accept: application/vnd.github+json")
+ suspend fun fetchLatestRelease(
+ @Path("owner") owner: String = "topjohnwu",
+ @Path("repo") repo: String = "Magisk",
+ ): Release
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/data/SuLogDao.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/data/SuLogDao.kt
new file mode 100644
index 0000000..4f05f10
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/data/SuLogDao.kt
@@ -0,0 +1,53 @@
+package com.topjohnwu.magisk.core.data
+
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.RoomDatabase
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.topjohnwu.magisk.core.model.su.SuLog
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.Calendar
+
+@Database(version = 2, entities = [SuLog::class], exportSchema = false)
+abstract class SuLogDatabase : RoomDatabase() {
+
+ abstract fun suLogDao(): SuLogDao
+
+ companion object {
+ val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) = with(db) {
+ execSQL("ALTER TABLE logs ADD COLUMN target INTEGER NOT NULL DEFAULT -1")
+ execSQL("ALTER TABLE logs ADD COLUMN context TEXT NOT NULL DEFAULT ''")
+ execSQL("ALTER TABLE logs ADD COLUMN gids TEXT NOT NULL DEFAULT ''")
+ }
+ }
+ }
+}
+
+@Dao
+abstract class SuLogDao(private val db: SuLogDatabase) {
+
+ private val twoWeeksAgo =
+ Calendar.getInstance().apply { add(Calendar.WEEK_OF_YEAR, -2) }.timeInMillis
+
+ suspend fun deleteAll() = withContext(Dispatchers.IO) { db.clearAllTables() }
+
+ suspend fun fetchAll(): MutableList {
+ deleteOutdated()
+ return fetch()
+ }
+
+ @Query("SELECT * FROM logs ORDER BY time DESC")
+ protected abstract suspend fun fetch(): MutableList
+
+ @Query("DELETE FROM logs WHERE time < :timeout")
+ protected abstract suspend fun deleteOutdated(timeout: Long = twoWeeksAgo)
+
+ @Insert
+ abstract suspend fun insert(log: SuLog)
+
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/MagiskDB.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/MagiskDB.kt
new file mode 100644
index 0000000..ff44ed1
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/MagiskDB.kt
@@ -0,0 +1,54 @@
+package com.topjohnwu.magisk.core.data.magiskdb
+
+import com.topjohnwu.magisk.core.ktx.await
+import com.topjohnwu.superuser.Shell
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+open class MagiskDB {
+
+ class Literal(
+ val str: String
+ )
+
+ suspend inline fun exec(
+ query: String,
+ crossinline mapper: (Map) -> R
+ ): List {
+ return withContext(Dispatchers.IO) {
+ val out = Shell.cmd("magisk --sqlite '$query'").await().out
+ out.map { line ->
+ line.split("\\|".toRegex())
+ .map { it.split("=", limit = 2) }
+ .filter { it.size == 2 }
+ .associate { it[0] to it[1] }
+ .let(mapper)
+ }
+ }
+ }
+
+ suspend fun exec(query: String) {
+ withContext(Dispatchers.IO) {
+ Shell.cmd("magisk --sqlite '$query'").await()
+ }
+ }
+
+ fun Map.toQuery(): String {
+ val keys = this.keys.joinToString(",")
+ val values = this.values.joinToString(",") {
+ when (it) {
+ is Boolean -> if (it) "1" else "0"
+ is Number -> it.toString()
+ is Literal -> it.str
+ else -> "\"$it\""
+ }
+ }
+ return "($keys) VALUES($values)"
+ }
+
+ object Table {
+ const val POLICY = "policies"
+ const val SETTINGS = "settings"
+ const val STRINGS = "strings"
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/PolicyDao.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/PolicyDao.kt
new file mode 100644
index 0000000..df38149
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/PolicyDao.kt
@@ -0,0 +1,60 @@
+package com.topjohnwu.magisk.core.data.magiskdb
+
+import com.topjohnwu.magisk.core.AppContext
+import com.topjohnwu.magisk.core.Const
+import com.topjohnwu.magisk.core.model.su.SuPolicy
+
+private const val SELECT_QUERY = "SELECT (until - strftime(\"%s\", \"now\")) AS remain, *"
+
+class PolicyDao : MagiskDB() {
+
+ suspend fun deleteOutdated() {
+ val query = "DELETE FROM ${Table.POLICY} WHERE " +
+ "(until > 0 AND until < strftime(\"%s\", \"now\")) OR until < 0"
+ exec(query)
+ }
+
+ suspend fun delete(uid: Int) {
+ val query = "DELETE FROM ${Table.POLICY} WHERE uid=$uid"
+ exec(query)
+ }
+
+ suspend fun fetch(uid: Int): SuPolicy? {
+ val query = "$SELECT_QUERY FROM ${Table.POLICY} WHERE uid=$uid LIMIT 1"
+ return exec(query, ::toPolicy).firstOrNull()
+ }
+
+ suspend fun update(policy: SuPolicy) {
+ val map = policy.toMap()
+ if (!Const.Version.atLeast_25_0()) {
+ // Put in package_name for old database
+ map["package_name"] = AppContext.packageManager.getNameForUid(policy.uid)!!
+ }
+ val query = "REPLACE INTO ${Table.POLICY} ${map.toQuery()}"
+ exec(query)
+ }
+
+ suspend fun fetchAll(): List {
+ val query = "$SELECT_QUERY FROM ${Table.POLICY} WHERE uid/100000=${Const.USER_ID}"
+ return exec(query, ::toPolicy).filterNotNull()
+ }
+
+ private fun toPolicy(map: Map): SuPolicy? {
+ val uid = map["uid"]?.toInt() ?: return null
+ val policy = SuPolicy(uid)
+
+ map["until"]?.toLong()?.let { until ->
+ if (until <= 0) {
+ policy.remain = until
+ } else {
+ map["remain"]?.toLong()?.let { policy.remain = it }
+ }
+ }
+
+ map["policy"]?.toInt()?.let { policy.policy = it }
+ map["logging"]?.toInt()?.let { policy.logging = it != 0 }
+ map["notification"]?.toInt()?.let { policy.notification = it != 0 }
+ return policy
+ }
+
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/SettingsDao.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/SettingsDao.kt
new file mode 100644
index 0000000..6089caf
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/SettingsDao.kt
@@ -0,0 +1,20 @@
+package com.topjohnwu.magisk.core.data.magiskdb
+
+class SettingsDao : MagiskDB() {
+
+ suspend fun delete(key: String) {
+ val query = "DELETE FROM ${Table.SETTINGS} WHERE key=\"$key\""
+ exec(query)
+ }
+
+ suspend fun put(key: String, value: Int) {
+ val kv = mapOf("key" to key, "value" to value)
+ val query = "REPLACE INTO ${Table.SETTINGS} ${kv.toQuery()}"
+ exec(query)
+ }
+
+ suspend fun fetch(key: String, default: Int = -1): Int {
+ val query = "SELECT value FROM ${Table.SETTINGS} WHERE key=\"$key\" LIMIT 1"
+ return exec(query) { it["value"]?.toInt() }.firstOrNull() ?: default
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/StringDao.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/StringDao.kt
new file mode 100644
index 0000000..2ea488a
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/data/magiskdb/StringDao.kt
@@ -0,0 +1,20 @@
+package com.topjohnwu.magisk.core.data.magiskdb
+
+class StringDao : MagiskDB() {
+
+ suspend fun delete(key: String) {
+ val query = "DELETE FROM ${Table.STRINGS} WHERE key=\"$key\""
+ exec(query)
+ }
+
+ suspend fun put(key: String, value: String) {
+ val kv = mapOf("key" to key, "value" to value)
+ val query = "REPLACE INTO ${Table.STRINGS} ${kv.toQuery()}"
+ exec(query)
+ }
+
+ suspend fun fetch(key: String, default: String = ""): String {
+ val query = "SELECT value FROM ${Table.STRINGS} WHERE key=\"$key\" LIMIT 1"
+ return exec(query) { it["value"] }.firstOrNull() ?: default
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/di/Networking.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/di/Networking.kt
new file mode 100644
index 0000000..1c262e1
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/di/Networking.kt
@@ -0,0 +1,97 @@
+package com.topjohnwu.magisk.core.di
+
+import android.content.Context
+import com.squareup.moshi.Moshi
+import com.topjohnwu.magisk.ProviderInstaller
+import com.topjohnwu.magisk.core.BuildConfig
+import com.topjohnwu.magisk.core.Config
+import com.topjohnwu.magisk.core.model.DateTimeAdapter
+import com.topjohnwu.magisk.core.utils.LocaleSetting
+import okhttp3.Cache
+import okhttp3.ConnectionSpec
+import okhttp3.Dns
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.dnsoverhttps.DnsOverHttps
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import retrofit2.converter.scalars.ScalarsConverterFactory
+import java.io.File
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+private class DnsResolver(client: OkHttpClient) : Dns {
+
+ private val doh by lazy {
+ DnsOverHttps.Builder().client(client)
+ .url("https://cloudflare-dns.com/dns-query".toHttpUrl())
+ .bootstrapDnsHosts(listOf(
+ InetAddress.getByName("162.159.36.1"),
+ InetAddress.getByName("162.159.46.1"),
+ InetAddress.getByName("1.1.1.1"),
+ InetAddress.getByName("1.0.0.1"),
+ InetAddress.getByName("2606:4700:4700::1111"),
+ InetAddress.getByName("2606:4700:4700::1001"),
+ InetAddress.getByName("2606:4700:4700::0064"),
+ InetAddress.getByName("2606:4700:4700::6400")
+ ))
+ .resolvePrivateAddresses(true) /* To make PublicSuffixDatabase never used */
+ .build()
+ }
+
+ override fun lookup(hostname: String): List {
+ if (Config.doh) {
+ try {
+ return doh.lookup(hostname)
+ } catch (e: UnknownHostException) {}
+ }
+ return Dns.SYSTEM.lookup(hostname)
+ }
+}
+
+
+fun createOkHttpClient(context: Context): OkHttpClient {
+ val appCache = Cache(File(context.cacheDir, "okhttp"), 10 * 1024 * 1024)
+ val builder = OkHttpClient.Builder().cache(appCache)
+
+ if (BuildConfig.DEBUG) {
+ builder.addInterceptor(HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ })
+ } else {
+ builder.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
+ }
+
+ builder.dns(DnsResolver(builder.build()))
+
+ builder.addInterceptor { chain ->
+ val request = chain.request().newBuilder()
+ request.header("User-Agent", "Magisk/${BuildConfig.APP_VERSION_CODE}")
+ request.header("Accept-Language", LocaleSetting.instance.currentLocale.toLanguageTag())
+ chain.proceed(request.build())
+ }
+
+ ProviderInstaller.install(context)
+
+ return builder.build()
+}
+
+fun createMoshiConverterFactory(): MoshiConverterFactory {
+ val moshi = Moshi.Builder().add(DateTimeAdapter()).build()
+ return MoshiConverterFactory.create(moshi)
+}
+
+fun createRetrofit(okHttpClient: OkHttpClient): Retrofit.Builder {
+ return Retrofit.Builder()
+ .addConverterFactory(ScalarsConverterFactory.create())
+ .addConverterFactory(createMoshiConverterFactory())
+ .client(okHttpClient)
+}
+
+inline fun createApiService(retrofitBuilder: Retrofit.Builder, baseUrl: String): T {
+ return retrofitBuilder
+ .baseUrl(baseUrl)
+ .build()
+ .create(T::class.java)
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/di/ServiceLocator.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/di/ServiceLocator.kt
new file mode 100644
index 0000000..89913ae
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/di/ServiceLocator.kt
@@ -0,0 +1,58 @@
+package com.topjohnwu.magisk.core.di
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.text.method.LinkMovementMethod
+import androidx.room.Room
+import com.topjohnwu.magisk.core.AppContext
+import com.topjohnwu.magisk.core.Const
+import com.topjohnwu.magisk.core.data.SuLogDatabase
+import com.topjohnwu.magisk.core.data.magiskdb.PolicyDao
+import com.topjohnwu.magisk.core.data.magiskdb.SettingsDao
+import com.topjohnwu.magisk.core.data.magiskdb.StringDao
+import com.topjohnwu.magisk.core.ktx.deviceProtectedContext
+import com.topjohnwu.magisk.core.repository.LogRepository
+import com.topjohnwu.magisk.core.repository.NetworkService
+import io.noties.markwon.Markwon
+import io.noties.markwon.utils.NoCopySpannableFactory
+
+@SuppressLint("StaticFieldLeak")
+object ServiceLocator {
+
+ val deContext by lazy { AppContext.deviceProtectedContext }
+ val timeoutPrefs by lazy { deContext.getSharedPreferences("su_timeout", 0) }
+
+ // Database
+ val policyDB = PolicyDao()
+ val settingsDB = SettingsDao()
+ val stringDB = StringDao()
+ val sulogDB by lazy { createSuLogDatabase(deContext).suLogDao() }
+ val logRepo by lazy { LogRepository(sulogDB) }
+
+ // Networking
+ val okhttp by lazy { createOkHttpClient(AppContext) }
+ val retrofit by lazy { createRetrofit(okhttp) }
+ val markwon by lazy { createMarkwon(AppContext) }
+ val networkService by lazy {
+ NetworkService(
+ createApiService(retrofit, Const.Url.INVALID_URL),
+ createApiService(retrofit, Const.Url.GITHUB_API_URL),
+ )
+ }
+}
+
+private fun createSuLogDatabase(context: Context) =
+ Room.databaseBuilder(context, SuLogDatabase::class.java, "sulogs.db")
+ .addMigrations(SuLogDatabase.MIGRATION_1_2)
+ .fallbackToDestructiveMigration(true)
+ .build()
+
+private fun createMarkwon(context: Context) =
+ Markwon.builder(context).textSetter { textView, spanned, bufferType, onComplete ->
+ textView.apply {
+ movementMethod = LinkMovementMethod.getInstance()
+ setSpannableFactory(NoCopySpannableFactory.getInstance())
+ setText(spanned, bufferType)
+ onComplete.run()
+ }
+ }.build()
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt
new file mode 100644
index 0000000..343031f
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt
@@ -0,0 +1,266 @@
+package com.topjohnwu.magisk.core.download
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.PendingIntent
+import android.app.job.JobInfo
+import android.app.job.JobScheduler
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.collection.SparseArrayCompat
+import androidx.collection.isNotEmpty
+import androidx.core.content.getSystemService
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MutableLiveData
+import com.topjohnwu.magisk.core.AppContext
+import com.topjohnwu.magisk.core.Const
+import com.topjohnwu.magisk.core.JobService
+import com.topjohnwu.magisk.core.R
+import com.topjohnwu.magisk.core.base.IActivityExtension
+import com.topjohnwu.magisk.core.cmp
+import com.topjohnwu.magisk.core.di.ServiceLocator
+import com.topjohnwu.magisk.core.intent
+import com.topjohnwu.magisk.core.ktx.set
+import com.topjohnwu.magisk.core.utils.ProgressInputStream
+import com.topjohnwu.magisk.view.Notifications
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import okhttp3.ResponseBody
+import timber.log.Timber
+import java.io.InputStream
+
+/**
+ * This class drives the execution of file downloads and notification management.
+ *
+ * Each download engine instance has to be paired with a "session" that is managed by the operating
+ * system. A session is an Android component that allows executing long lasting operations and
+ * have its state tied to a notification to show progress.
+ *
+ * A session can only have one single notification representing its state, and the operating system
+ * also uses the notification to manage the lifecycle of a session. One goal of this class is
+ * to support concurrent download tasks using only one single session, so internally it manages
+ * all active tasks and notifications and properly re-assign notifications to be attached to
+ * the session to make sure all download operations can be completed without the operating system
+ * killing the session.
+ *
+ * For API 23 - 33, we use a foreground service as a session.
+ * For API 34 and higher, we use user-initiated job services as a session.
+ */
+class DownloadEngine(session: DownloadSession) : DownloadSession by session, DownloadNotifier {
+
+ companion object {
+ const val ACTION = "com.topjohnwu.magisk.DOWNLOAD"
+ const val SUBJECT_KEY = "subject"
+ private const val REQUEST_CODE = 1
+
+ private val progressBroadcast = MutableLiveData?>()
+
+ private fun broadcast(progress: Float, subject: Subject) {
+ progressBroadcast.postValue(progress to subject)
+ }
+
+ fun observeProgress(owner: LifecycleOwner, callback: (Float, Subject) -> Unit) {
+ progressBroadcast.value = null
+ progressBroadcast.observe(owner) {
+ val (progress, subject) = it ?: return@observe
+ callback(progress, subject)
+ }
+ }
+
+ private fun createBroadcastIntent(context: Context, subject: Subject) =
+ context.intent()
+ .setAction(ACTION)
+ .putExtra(SUBJECT_KEY, subject)
+
+ private fun createServiceIntent(context: Context, subject: Subject) =
+ context.intent()
+ .setAction(ACTION)
+ .putExtra(SUBJECT_KEY, subject)
+
+ @SuppressLint("InlinedApi")
+ fun getPendingIntent(context: Context, subject: Subject): PendingIntent {
+ val flag = PendingIntent.FLAG_IMMUTABLE or
+ PendingIntent.FLAG_UPDATE_CURRENT or
+ PendingIntent.FLAG_ONE_SHOT
+ return if (Build.VERSION.SDK_INT >= 34) {
+ // On API 34+, download tasks are handled with a user-initiated job.
+ // However, there is no way to schedule a new job directly with a pending intent.
+ // As a workaround, we send the subject to a broadcast receiver and have it
+ // schedule the job for us.
+ val intent = createBroadcastIntent(context, subject)
+ PendingIntent.getBroadcast(context, REQUEST_CODE, intent, flag)
+ } else {
+ val intent = createServiceIntent(context, subject)
+ if (Build.VERSION.SDK_INT >= 26) {
+ PendingIntent.getForegroundService(context, REQUEST_CODE, intent, flag)
+ } else {
+ PendingIntent.getService(context, REQUEST_CODE, intent, flag)
+ }
+ }
+ }
+
+ @SuppressLint("InlinedApi")
+ fun startWithActivity(
+ activity: T,
+ subject: Subject
+ ) where T : ComponentActivity, T : IActivityExtension {
+ activity.withPermission(Manifest.permission.POST_NOTIFICATIONS) {
+ // Always download regardless of notification permission status
+ start(activity.applicationContext, subject)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun start(context: Context, subject: Subject) {
+ if (Build.VERSION.SDK_INT >= 34) {
+ val scheduler = context.getSystemService()!!
+ val cmp = JobService::class.java.cmp(context.packageName)
+ val extras = Bundle()
+ extras.putParcelable(SUBJECT_KEY, subject)
+ val info = JobInfo.Builder(Const.ID.DOWNLOAD_JOB_ID, cmp)
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .setUserInitiated(true)
+ .setTransientExtras(extras)
+ .build()
+ scheduler.schedule(info)
+ } else {
+ val intent = createServiceIntent(context, subject)
+ if (Build.VERSION.SDK_INT >= 26) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+ }
+ }
+
+ private val notifications = SparseArrayCompat()
+ private var attachedId = -1
+ private val job = Job()
+ private val processor = DownloadProcessor(this)
+ private val network get() = ServiceLocator.networkService
+
+ fun download(subject: Subject) {
+ notifyUpdate(subject.notifyId)
+ CoroutineScope(job + Dispatchers.IO).launch {
+ try {
+ val stream = network.fetchFile(subject.url).toProgressStream(subject)
+ processor.handle(stream, subject)
+ val activity = AppContext.foregroundActivity
+ if (activity != null && subject.autoLaunch) {
+ notifyRemove(subject.notifyId)
+ subject.pendingIntent(activity)?.send()
+ } else {
+ notifyFinish(subject)
+ }
+ } catch (e: Exception) {
+ Timber.e(e)
+ notifyFail(subject)
+ }
+ }
+ }
+
+ @Synchronized
+ fun reattach() {
+ val builder = notifications[attachedId] ?: return
+ attachNotification(attachedId, builder)
+ }
+
+ private fun attach(id: Int, notification: Notification.Builder) {
+ attachedId = id
+ attachNotification(id, notification)
+ }
+
+ private fun finalNotify(id: Int, editor: (Notification.Builder) -> Unit): Int {
+ val notification = notifyRemove(id)?.also(editor) ?: return -1
+ val newId = Notifications.nextId()
+ Notifications.mgr.notify(newId, notification.build())
+ return newId
+ }
+
+ private fun notifyFail(subject: Subject) = finalNotify(subject.notifyId) {
+ broadcast(-2f, subject)
+ it.setContentText(context.getString(R.string.download_file_error))
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setOngoing(false)
+ }
+
+ private fun notifyFinish(subject: Subject) = finalNotify(subject.notifyId) {
+ broadcast(1f, subject)
+ it.setContentTitle(subject.title)
+ .setContentText(context.getString(R.string.download_complete))
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setProgress(0, 0, false)
+ .setOngoing(false)
+ .setAutoCancel(true)
+ subject.pendingIntent(context)?.let { intent -> it.setContentIntent(intent) }
+ }
+
+ @Synchronized
+ override fun notifyUpdate(id: Int, editor: (Notification.Builder) -> Unit) {
+ val notification = (notifications[id] ?: Notifications.startProgress("").also {
+ notifications[id] = it
+ }).apply(editor)
+
+ if (attachedId < 0)
+ attach(id, notification)
+ else
+ Notifications.mgr.notify(id, notification.build())
+ }
+
+ @Synchronized
+ private fun notifyRemove(id: Int): Notification.Builder? {
+ val idx = notifications.indexOfKey(id)
+ var n: Notification.Builder? = null
+
+ if (idx >= 0) {
+ n = notifications.valueAt(idx)
+ notifications.removeAt(idx)
+
+ // The cancelled notification is the one attached to the session, need special handling
+ if (attachedId == id) {
+ if (notifications.isNotEmpty()) {
+ // There are still remaining notifications, pick one and attach to the session
+ val anotherId = notifications.keyAt(0)
+ val notification = notifications.valueAt(0)
+ attach(anotherId, notification)
+ } else {
+ // No more notifications left, terminate the session
+ attachedId = -1
+ onDownloadComplete()
+ }
+ }
+ }
+
+ Notifications.mgr.cancel(id)
+ return n
+ }
+
+ private fun ResponseBody.toProgressStream(subject: Subject): InputStream {
+ val max = contentLength()
+ val total = max.toFloat() / 1048576
+ val id = subject.notifyId
+
+ notifyUpdate(id) { it.setContentTitle(subject.title) }
+
+ return ProgressInputStream(byteStream()) {
+ val progress = it.toFloat() / 1048576
+ notifyUpdate(id) { notification ->
+ if (max > 0) {
+ broadcast(progress / total, subject)
+ notification
+ .setProgress(max.toInt(), it.toInt(), false)
+ .setContentText("%.2f / %.2f MB".format(progress, total))
+ } else {
+ broadcast(-1f, subject)
+ notification.setContentText("%.2f MB / ??".format(progress))
+ }
+ }
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/download/DownloadProcessor.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/download/DownloadProcessor.kt
new file mode 100644
index 0000000..97a6f25
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/download/DownloadProcessor.kt
@@ -0,0 +1,122 @@
+package com.topjohnwu.magisk.core.download
+
+import android.net.Uri
+import com.topjohnwu.magisk.StubApk
+import com.topjohnwu.magisk.core.R
+import com.topjohnwu.magisk.core.isRunningAsStub
+import com.topjohnwu.magisk.core.ktx.cachedFile
+import com.topjohnwu.magisk.core.ktx.copyAll
+import com.topjohnwu.magisk.core.ktx.copyAndClose
+import com.topjohnwu.magisk.core.ktx.withInOut
+import com.topjohnwu.magisk.core.ktx.writeTo
+import com.topjohnwu.magisk.core.tasks.AppMigration
+import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
+import com.topjohnwu.magisk.utils.APKInstall
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
+import org.apache.commons.compress.archivers.zip.ZipFile
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+class DownloadProcessor(notifier: DownloadNotifier) : DownloadNotifier by notifier {
+
+ suspend fun handle(stream: InputStream, subject: Subject) {
+ when (subject) {
+ is Subject.App -> handleApp(stream, subject)
+ is Subject.Module -> handleModule(stream, subject.file)
+ else -> stream.copyAndClose(subject.file.outputStream())
+ }
+ }
+
+ suspend fun handleApp(stream: InputStream, subject: Subject.App) {
+ val external = subject.file.outputStream()
+
+ if (isRunningAsStub) {
+ val updateApk = StubApk.update(context)
+ try {
+ // Download full APK to stub update path
+ stream.copyAndClose(TeeOutputStream(external, updateApk.outputStream()))
+
+ // Also upgrade stub
+ notifyUpdate(subject.notifyId) {
+ it.setProgress(0, 0, true)
+ .setContentTitle(context.getString(R.string.hide_app_title))
+ .setContentText("")
+ }
+
+ // Extract stub
+ val apk = context.cachedFile("stub.apk")
+ ZipFile.Builder().setFile(updateApk).get().use { zf ->
+ apk.delete()
+ zf.getInputStream(zf.getEntry("assets/stub.apk")).writeTo(apk)
+ }
+
+ // Patch and install
+ subject.intent = AppMigration.upgradeStub(context, apk)
+ ?: throw IOException("HideAPK patch error")
+ apk.delete()
+ } catch (e: Exception) {
+ // If any error occurred, do not let stub load the new APK
+ updateApk.delete()
+ throw e
+ }
+ } else {
+ val session = APKInstall.startSession(context)
+ stream.copyAndClose(TeeOutputStream(external, session.openStream(context)))
+ subject.intent = session.waitIntent()
+ }
+ }
+
+ suspend fun handleModule(src: InputStream, file: Uri) {
+ val tmp = context.cachedFile("module.zip")
+ try {
+ // First download the entire zip into cache so we can process it
+ src.writeTo(tmp)
+
+ val input = ZipFile.Builder().setFile(tmp).get()
+ val output = ZipArchiveOutputStream(file.outputStream())
+ withInOut(input, output) { zin, zout ->
+ zout.putArchiveEntry(ZipArchiveEntry("META-INF/"))
+ zout.closeArchiveEntry()
+ zout.putArchiveEntry(ZipArchiveEntry("META-INF/com/"))
+ zout.closeArchiveEntry()
+ zout.putArchiveEntry(ZipArchiveEntry("META-INF/com/google/"))
+ zout.closeArchiveEntry()
+ zout.putArchiveEntry(ZipArchiveEntry("META-INF/com/google/android/"))
+ zout.closeArchiveEntry()
+
+ zout.putArchiveEntry(ZipArchiveEntry("META-INF/com/google/android/update-binary"))
+ context.assets.open("module_installer.sh").use { it.copyAll(zout) }
+ zout.closeArchiveEntry()
+
+ zout.putArchiveEntry(ZipArchiveEntry("META-INF/com/google/android/updater-script"))
+ zout.write("#MAGISK\n".toByteArray())
+ zout.closeArchiveEntry()
+
+ // Then simply copy all entries to output
+ zin.copyRawEntries(zout) { entry -> !entry.name.startsWith("META-INF") }
+ }
+ } finally {
+ tmp.delete()
+ }
+ }
+
+ private class TeeOutputStream(
+ private val o1: OutputStream,
+ private val o2: OutputStream
+ ) : OutputStream() {
+ override fun write(b: Int) {
+ o1.write(b)
+ o2.write(b)
+ }
+ override fun write(b: ByteArray?, off: Int, len: Int) {
+ o1.write(b, off, len)
+ o2.write(b, off, len)
+ }
+ override fun close() {
+ o1.close()
+ o2.close()
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/download/Interfaces.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/download/Interfaces.kt
new file mode 100644
index 0000000..bdc68a7
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/download/Interfaces.kt
@@ -0,0 +1,15 @@
+package com.topjohnwu.magisk.core.download
+
+import android.app.Notification
+import android.content.Context
+
+interface DownloadSession {
+ val context: Context
+ fun attachNotification(id: Int, builder: Notification.Builder)
+ fun onDownloadComplete()
+}
+
+interface DownloadNotifier {
+ val context: Context
+ fun notifyUpdate(id: Int, editor: (Notification.Builder) -> Unit = {})
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt
new file mode 100644
index 0000000..3a1ea39
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt
@@ -0,0 +1,72 @@
+package com.topjohnwu.magisk.core.download
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Parcelable
+import androidx.core.net.toUri
+import com.topjohnwu.magisk.core.Info
+import com.topjohnwu.magisk.core.model.UpdateInfo
+import com.topjohnwu.magisk.core.model.module.OnlineModule
+import com.topjohnwu.magisk.core.utils.MediaStoreUtils
+import com.topjohnwu.magisk.view.Notifications
+import kotlinx.parcelize.IgnoredOnParcel
+import kotlinx.parcelize.Parcelize
+import java.io.File
+import java.util.UUID
+
+abstract class Subject : Parcelable {
+
+ abstract val url: String
+ abstract val file: Uri
+ abstract val title: String
+ abstract val notifyId: Int
+ open val autoLaunch: Boolean get() = true
+
+ open fun pendingIntent(context: Context): PendingIntent? = null
+
+ abstract class Module : Subject() {
+ abstract val module: OnlineModule
+ final override val url: String get() = module.zipUrl
+ final override val title: String get() = module.downloadFilename
+ final override val file by lazy {
+ MediaStoreUtils.getFile(title).uri
+ }
+ }
+
+ @Parcelize
+ class App(
+ private val json: UpdateInfo = Info.update,
+ override val notifyId: Int = Notifications.nextId()
+ ) : Subject() {
+ override val title: String get() = "Magisk-${json.version}(${json.versionCode})"
+ override val url: String get() = json.link
+
+ @IgnoredOnParcel
+ override val file by lazy {
+ MediaStoreUtils.getFile("${title}.apk").uri
+ }
+
+ @IgnoredOnParcel
+ var intent: Intent? = null
+ override fun pendingIntent(context: Context) = intent?.toPending(context)
+ }
+
+ @Parcelize
+ class Test(
+ override val notifyId: Int = Notifications.nextId(),
+ override val title: String = UUID.randomUUID().toString().substring(0, 6)
+ ) : Subject() {
+ override val url get() = "https://link.testfile.org/250MB"
+ override val file get() = File("/dev/null").toUri()
+ override val autoLaunch get() = false
+ }
+
+ @SuppressLint("InlinedApi")
+ protected fun Intent.toPending(context: Context): PendingIntent {
+ return PendingIntent.getActivity(context, notifyId, this,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT)
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XAndroid.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XAndroid.kt
new file mode 100644
index 0000000..3a172e0
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XAndroid.kt
@@ -0,0 +1,149 @@
+package com.topjohnwu.magisk.core.ktx
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.AdaptiveIconDrawable
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.LayerDrawable
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import android.os.Process
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import android.widget.Toast
+import androidx.core.content.getSystemService
+import com.topjohnwu.magisk.core.utils.LocaleSetting
+import com.topjohnwu.magisk.core.utils.RootUtils
+import com.topjohnwu.magisk.utils.APKInstall
+import com.topjohnwu.superuser.internal.UiThreadHandler
+import java.io.File
+
+fun Context.getBitmap(id: Int): Bitmap {
+ var drawable = getDrawable(id)!!
+ if (drawable is BitmapDrawable)
+ return drawable.bitmap
+ if (SDK_INT >= Build.VERSION_CODES.O && drawable is AdaptiveIconDrawable) {
+ drawable = LayerDrawable(arrayOf(drawable.background, drawable.foreground))
+ }
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth, drawable.intrinsicHeight,
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ return bitmap
+}
+
+val Context.deviceProtectedContext: Context get() =
+ if (SDK_INT >= Build.VERSION_CODES.N) {
+ createDeviceProtectedStorageContext()
+ } else { this }
+
+fun Context.cachedFile(name: String) = File(cacheDir, name)
+
+fun ApplicationInfo.getLabel(pm: PackageManager): String {
+ runCatching {
+ if (labelRes > 0) {
+ val res = pm.getResourcesForApplication(this)
+ LocaleSetting.instance.updateResource(res)
+ return res.getString(labelRes)
+ }
+ }
+
+ return loadLabel(pm).toString()
+}
+
+fun Context.unwrap(): Context {
+ var context = this
+ while (context is ContextWrapper)
+ context = context.baseContext
+ return context
+}
+
+fun Activity.hideKeyboard() {
+ val view = currentFocus ?: return
+ getSystemService()
+ ?.hideSoftInputFromWindow(view.windowToken, 0)
+ view.clearFocus()
+}
+
+val View.activity: Activity get() {
+ var context = context
+ while(true) {
+ if (context !is ContextWrapper)
+ error("View is not attached to activity")
+ if (context is Activity)
+ return context
+ context = context.baseContext
+ }
+}
+
+@SuppressLint("PrivateApi")
+fun getProperty(key: String, def: String): String {
+ runCatching {
+ val clazz = Class.forName("android.os.SystemProperties")
+ val get = clazz.getMethod("get", String::class.java, String::class.java)
+ return get.invoke(clazz, key, def) as String
+ }
+ return def
+}
+
+@SuppressLint("InlinedApi")
+@Throws(PackageManager.NameNotFoundException::class)
+fun PackageManager.getPackageInfo(uid: Int, pid: Int): PackageInfo? {
+ val flag = PackageManager.MATCH_UNINSTALLED_PACKAGES
+ val pkgs = getPackagesForUid(uid) ?: throw PackageManager.NameNotFoundException()
+ if (pkgs.size > 1) {
+ if (pid <= 0) {
+ return null
+ }
+ // Try to find package name from PID
+ val proc = RootUtils.getAppProcess(pid)
+ if (proc == null) {
+ if (uid == Process.SHELL_UID) {
+ // It is possible that some apps installed are sharing UID with shell.
+ // We will not be able to find a package from the active process list,
+ // because the client is forked from ADB shell, not any app process.
+ return getPackageInfo("com.android.shell", flag)
+ }
+ } else if (uid == proc.uid) {
+ return getPackageInfo(proc.pkgList[0], flag)
+ }
+
+ return null
+ }
+ if (pkgs.size == 1) {
+ return getPackageInfo(pkgs[0], flag)
+ }
+ throw PackageManager.NameNotFoundException()
+}
+
+fun Context.registerRuntimeReceiver(receiver: BroadcastReceiver, filter: IntentFilter) {
+ APKInstall.registerReceiver(this, receiver, filter)
+}
+
+fun Context.selfLaunchIntent(): Intent {
+ val pm = packageManager
+ val intent = pm.getLaunchIntentForPackage(packageName)!!
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ return intent
+}
+
+fun Context.toast(msg: CharSequence, duration: Int) {
+ UiThreadHandler.run { Toast.makeText(this, msg, duration).show() }
+}
+
+fun Context.toast(resId: Int, duration: Int) {
+ UiThreadHandler.run { Toast.makeText(this, resId, duration).show() }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt
new file mode 100644
index 0000000..cebd3b7
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt
@@ -0,0 +1,98 @@
+package com.topjohnwu.magisk.core.ktx
+
+import androidx.collection.SparseArrayCompat
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flatMapMerge
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withContext
+import java.io.Closeable
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.lang.reflect.Field
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Collections
+
+inline fun withInOut(
+ input: In,
+ output: Out,
+ withBoth: (In, Out) -> Unit
+) {
+ input.use { reader ->
+ output.use { writer ->
+ withBoth(reader, writer)
+ }
+ }
+}
+
+@Throws(IOException::class)
+suspend fun InputStream.copyAll(
+ out: OutputStream,
+ bufferSize: Int = DEFAULT_BUFFER_SIZE,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+): Long {
+ return withContext(dispatcher) {
+ var bytesCopied: Long = 0
+ val buffer = ByteArray(bufferSize)
+ var bytes = read(buffer)
+ while (isActive && bytes >= 0) {
+ out.write(buffer, 0, bytes)
+ bytesCopied += bytes
+ bytes = read(buffer)
+ }
+ bytesCopied
+ }
+}
+
+@Throws(IOException::class)
+suspend inline fun InputStream.copyAndClose(
+ out: OutputStream,
+ bufferSize: Int = DEFAULT_BUFFER_SIZE,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+) = withInOut(this, out) { i, o -> i.copyAll(o, bufferSize, dispatcher) }
+
+@Throws(IOException::class)
+suspend inline fun InputStream.writeTo(
+ file: File,
+ bufferSize: Int = DEFAULT_BUFFER_SIZE,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+) = copyAndClose(file.outputStream(), bufferSize, dispatcher)
+
+operator fun SparseArrayCompat.set(key: Int, value: E) {
+ put(key, value)
+}
+
+fun MutableList.synchronized(): MutableList = Collections.synchronizedList(this)
+
+fun MutableSet.synchronized(): MutableSet = Collections.synchronizedSet(this)
+
+fun MutableMap.synchronized(): MutableMap = Collections.synchronizedMap(this)
+
+fun Class<*>.reflectField(name: String): Field =
+ getDeclaredField(name).apply { isAccessible = true }
+
+inline fun Flow.concurrentMap(crossinline transform: suspend (T) -> R): Flow {
+ return flatMapMerge { value ->
+ flow { emit(transform(value)) }
+ }
+}
+
+fun Long.toTime(format: DateTimeFormatter): String = format.format(Instant.ofEpochMilli(this))
+
+// Some devices don't allow filenames containing ":"
+val timeFormatStandard: DateTimeFormatter by lazy {
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH.mm.ss").withZone(ZoneId.systemDefault())
+}
+val timeDateFormat: DateTimeFormatter by lazy {
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
+}
+val dateFormat: DateTimeFormatter by lazy {
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withZone(ZoneId.systemDefault())
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XSU.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XSU.kt
new file mode 100644
index 0000000..e6f035f
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/ktx/XSU.kt
@@ -0,0 +1,16 @@
+package com.topjohnwu.magisk.core.ktx
+
+import com.topjohnwu.magisk.core.Config
+import com.topjohnwu.superuser.Shell
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+fun reboot(reason: String = if (Config.recovery) "recovery" else "") {
+ if (reason == "recovery") {
+ // KEYCODE_POWER = 26, hide incorrect "Factory data reset" message
+ Shell.cmd("/system/bin/input keyevent 26").submit()
+ }
+ Shell.cmd("/system/bin/svc power reboot $reason || /system/bin/reboot $reason").submit()
+}
+
+suspend fun Shell.Job.await() = withContext(Dispatchers.IO) { exec() }
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt
new file mode 100644
index 0000000..f819466
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt
@@ -0,0 +1,68 @@
+package com.topjohnwu.magisk.core.model
+
+import android.os.Parcelable
+import com.squareup.moshi.FromJson
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.JsonQualifier
+import com.squareup.moshi.ToJson
+import kotlinx.parcelize.Parcelize
+import java.time.Instant
+
+@JsonClass(generateAdapter = true)
+class UpdateJson(
+ val magisk: UpdateInfo = UpdateInfo(),
+)
+
+@Parcelize
+@JsonClass(generateAdapter = true)
+data class UpdateInfo(
+ val version: String = "",
+ val versionCode: Int = -1,
+ val link: String = "",
+ val note: String = ""
+) : Parcelable
+
+@JsonClass(generateAdapter = true)
+data class ModuleJson(
+ val version: String,
+ val versionCode: Int,
+ val zipUrl: String,
+ val changelog: String,
+)
+
+@JsonClass(generateAdapter = true)
+data class ReleaseAssets(
+ val name: String,
+ @param:Json(name = "browser_download_url") val url: String,
+)
+
+class DateTimeAdapter {
+ @ToJson
+ fun toJson(date: Instant): String {
+ return date.toString()
+ }
+
+ @FromJson
+ fun fromJson(date: String): Instant {
+ return Instant.parse(date)
+ }
+}
+
+@JsonClass(generateAdapter = true)
+data class Release(
+ @param:Json(name = "tag_name") val tag: String,
+ val name: String,
+ val prerelease: Boolean,
+ val assets: List,
+ val body: String,
+ @param:Json(name = "created_at") val createdTime: Instant,
+) {
+ val versionCode: Int get() {
+ return if (tag[0] == 'v') {
+ (tag.drop(1).toFloat() * 1000).toInt()
+ } else {
+ tag.drop(7).toInt()
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/model/module/LocalModule.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/model/module/LocalModule.kt
new file mode 100644
index 0000000..a8dd43a
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/model/module/LocalModule.kt
@@ -0,0 +1,135 @@
+package com.topjohnwu.magisk.core.model.module
+
+import com.squareup.moshi.JsonDataException
+import com.topjohnwu.magisk.core.Const
+import com.topjohnwu.magisk.core.di.ServiceLocator
+import com.topjohnwu.magisk.core.utils.RootUtils
+import com.topjohnwu.superuser.Shell
+import com.topjohnwu.superuser.nio.ExtendedFile
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.IOException
+import java.util.Locale
+
+data class LocalModule(
+ val base: ExtendedFile,
+) : Module() {
+ private val svc get() = ServiceLocator.networkService
+
+ override var id: String = ""
+ override var name: String = ""
+ override var version: String = ""
+ override var versionCode: Int = -1
+ var author: String = ""
+ var description: String = ""
+ var updateInfo: OnlineModule? = null
+ var outdated = false
+ private var updateUrl: String = ""
+
+ private val removeFile = base.getChildFile("remove")
+ private val disableFile = base.getChildFile("disable")
+ private val updateFile = base.getChildFile("update")
+ val zygiskFolder = base.getChildFile("zygisk")
+
+ val updated get() = updateFile.exists()
+ val isRiru = (id == "riru-core") || base.getChildFile("riru").exists()
+ val isZygisk = zygiskFolder.exists()
+ val zygiskUnloaded = zygiskFolder.getChildFile("unloaded").exists()
+ val hasAction = base.getChildFile("action.sh").exists()
+
+ var enable: Boolean
+ get() = !disableFile.exists()
+ set(enable) {
+ if (enable) {
+ disableFile.delete()
+ Shell.cmd("copy_preinit_files").submit()
+ } else {
+ !disableFile.createNewFile()
+ Shell.cmd("copy_preinit_files").submit()
+ }
+ }
+
+ var remove: Boolean
+ get() = removeFile.exists()
+ set(remove) {
+ if (remove) {
+ if (updateFile.exists()) return
+ removeFile.createNewFile()
+ Shell.cmd("copy_preinit_files").submit()
+ } else {
+ removeFile.delete()
+ Shell.cmd("copy_preinit_files").submit()
+ }
+ }
+
+ @Throws(NumberFormatException::class)
+ private fun parseProps(props: List) {
+ for (line in props) {
+ val prop = line.split("=".toRegex(), 2).map { it.trim() }
+ if (prop.size != 2)
+ continue
+
+ val key = prop[0]
+ val value = prop[1]
+ if (key.isEmpty() || key[0] == '#')
+ continue
+
+ when (key) {
+ "id" -> id = value
+ "name" -> name = value
+ "version" -> version = value
+ "versionCode" -> versionCode = value.toInt()
+ "author" -> author = value
+ "description" -> description = value
+ "updateJson" -> updateUrl = value
+ }
+ }
+ }
+
+ init {
+ runCatching {
+ parseProps(Shell.cmd("dos2unix < $base/module.prop").exec().out)
+ }
+
+ if (id.isEmpty()) {
+ id = base.name
+ }
+
+ if (name.isEmpty()) {
+ name = id
+ }
+ }
+
+ suspend fun fetch(): Boolean {
+ if (updateUrl.isEmpty())
+ return false
+
+ try {
+ val json = svc.fetchModuleJson(updateUrl)
+ updateInfo = OnlineModule(this, json)
+ outdated = json.versionCode > versionCode
+ return true
+ } catch (e: IOException) {
+ Timber.w(e)
+ } catch (e: JsonDataException) {
+ Timber.w(e)
+ }
+
+ return false
+ }
+
+ companion object {
+
+ fun loaded() = RootUtils.fs.getFile(Const.MODULE_PATH).exists()
+
+ suspend fun installed() = withContext(Dispatchers.IO) {
+ RootUtils.fs.getFile(Const.MODULE_PATH)
+ .listFiles()
+ .orEmpty()
+ .filter { !it.isFile && !it.isHidden }
+ .map { LocalModule(it) }
+ .sortedBy { it.name.lowercase(Locale.ROOT) }
+ }
+ }
+}
diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/model/module/Module.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/model/module/Module.kt
new file mode 100644
index 0000000..859ca64
--- /dev/null
+++ b/app/core/src/main/java/com/topjohnwu/magisk/core/model/module/Module.kt
@@ -0,0 +1,14 @@
+package com.topjohnwu.magisk.core.model.module
+
+abstract class Module : Comparable