diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..83040d9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Reporting Security Issues + +The KernelSU team and community take security bugs in KernelSU seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. + +To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/tiann/KernelSU/security/advisories/new) tab, or you can mailto [weishu](mailto:twsxtd@gmail.com) directly. + +The KernelSU team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..ce0c41a --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,6 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_API_TOKEN +preserve_hierarchy: 1 +files: + - source: /manager/app/src/main/res/values/strings.xml + translation: /manager/app/src/main/res/values-%two_letters_code%/strings.xml diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..544d7a4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,101 @@ +# SukiSU Ultra +sukisu logo + + +**English** | [简体中文](./zh/README.md) | [日本語](./ja/README.md) | [Türkçe](./tr/README.md) + +A kernel-based root solution for Android devices, forked from [`tiann/KernelSU`](https://github.com/tiann/KernelSU), and added some interesting changes. + +[![Latest release](https://img.shields.io/github/v/release/SukiSU-Ultra/SukiSU-Ultra?label=Release&logo=github)](https://github.com/tiann/KernelSU/releases/latest) +[![Channel](https://img.shields.io/badge/Follow-Telegram-blue.svg?logo=telegram)](https://t.me/Sukiksu) +[![License: GPL v2](https://img.shields.io/badge/License-GPL%20v2-orange.svg?logo=gnu)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) +[![GitHub License](https://img.shields.io/github/license/tiann/KernelSU?logo=gnu)](/LICENSE) + +## Features + +1. Kernel-based `su` and root access management +2. Module system based on [Magic Mount](https://github.com/5ec1cff/KernelSU) +3. [App Profile](https://kernelsu.org/guide/app-profile.html): Lock up the root power in a cage +4. Support non-GKI and GKI 1.0 +5. KPM Support +6. Tweaks to the manager theme and the built-in susfs management tool. + +## Compatibility Status + +- KernelSU (before v1.0.0) officially supports Android GKI 2.0 devices (kernel 5.10+). + +- Older kernels (4.4+) are also compatible, but the kernel will have to be built manually. + +- With more backports, KernelSU can supports 3.x kernel (3.4-3.18). + +- Currently, only `arm64-v8a`, `armeabi-v7a (bare)` and `X86_64`(some) are supported. + +## Installation + +See [`guide/installation.md`](guide/installation.md) + +## Integration + +See [`guide/how-to-integrate.md`](guide/how-to-integrate.md) + +## Translation + +If you need to submit a translation for the manager, please go to [Crowdin](https://crowdin.com/project/SukiSU-Ultra). + +## KPM Support + +- Based on KernelPatch, we removed features redundant with KSU and retained only KPM support. +- Work in Progress: Expanding APatch compatibility by integrating additional functions to ensure compatibility across different implementations. + +**Open-source repository**: [https://github.com/ShirkNeko/SukiSU_KernelPatch_patch](https://github.com/ShirkNeko/SukiSU_KernelPatch_patch) + +**KPM template**: [https://github.com/udochina/KPM-Build-Anywhere](https://github.com/udochina/KPM-Build-Anywhere) + +> [!Note] +> +> 1. Requires `CONFIG_KPM=y` +> 2. Non-GKI devices requires `CONFIG_KALLSYMS=y` and `CONFIG_KALLSYMS_ALL=y` +> 3. For kernels below `4.19`, backporting from `set_memory.h` from `4.19` is required. + +## Troubleshooting + +1. Device stuck upon manager app uninstallation? + Uninstall _com.sony.playmemories.mobile_ + +## Sponsor + +- [ShirkNeko](https://afdian.com/a/shirkneko) (maintainer of SukiSU) +- [weishu](https://github.com/sponsors/tiann) (author of KernelSU) + +## ShirkNeko's sponsorship list + +- [Ktouls](https://github.com/Ktouls) Thanks so much for bringing me support. +- [zaoqi123](https://github.com/zaoqi123) Thanks for the milk tea. +- [wswzgdg](https://github.com/wswzgdg) Many thanks for supporting this project. +- [yspbwx2010](https://github.com/yspbwx2010) Many thanks. +- [DARKWWEE](https://github.com/DARKWWEE) 100 USDT +- [Saksham Singla](https://github.com/TypeFlu) Provide and maintain the website +- [OukaroMF](https://github.com/OukaroMF) Donation of website domain name + +## License + +- The file in the “kernel” directory is under [GPL-2.0-only](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) license. +- The images of the files `ic_launcher(?!.*alt.*).*` with anime character sticker are copyrighted by [怡子曰曰](https://space.bilibili.com/10545509), the Brand Intellectual Property in the images is owned by [明风 OuO](https://space.bilibili.com/274939213), and the vectorization is done by @MiRinChan. Before using these files, in addition to complying with [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt), you also need to comply with the authorization of the two authors to use these artistic contents. +- Except for the files or directories mentioned above, all other parts are under [GPL-3.0 or later](https://www.gnu.org/licenses/gpl-3.0.html) license. + +## Credit + +- [KernelSU](https://github.com/tiann/KernelSU): upstream +- [MKSU](https://github.com/5ec1cff/KernelSU): Magic Mount +- [RKSU](https://github.com/rsuntk/KernelsU): support non-GKI +- [susfs](https://gitlab.com/simonpunk/susfs4ksu): An addon root hiding kernel patches and userspace module for KernelSU. +- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch is a key part of the APatch implementation of the kernel module + +
+KernelSU's credit + +- [Kernel-Assisted Superuser](https://git.zx2c4.com/kernel-assisted-superuser/about/): The KernelSU idea. +- [Magisk](https://github.com/topjohnwu/Magisk): The powerful root tool. +- [genuine](https://github.com/brevent/genuine/): APK v2 signature validation. +- [Diamorphine](https://github.com/m0nad/Diamorphine): Some rootkit skills. +
diff --git a/docs/SukiSU-mini.svg b/docs/SukiSU-mini.svg new file mode 100644 index 0000000..1c7d20f --- /dev/null +++ b/docs/SukiSU-mini.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/SukiSU.svg b/docs/SukiSU.svg new file mode 100644 index 0000000..559a5d5 --- /dev/null +++ b/docs/SukiSU.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/guide/how-to-integrate.md b/docs/guide/how-to-integrate.md new file mode 100644 index 0000000..2f15480 --- /dev/null +++ b/docs/guide/how-to-integrate.md @@ -0,0 +1,97 @@ +# Integrate + +SukiSU can be integrated into both _GKI_ and _non-GKI_ kernels and has been backported to _4.14_. + + + +Some OEMs' customization could result in as much as 50% of kernel code being out-of-tree code and not from upstream Linux kernels or ACKs. Due to this, the custom nature of _non-GKI_ kernels resulted in significant kernel fragmentation, and we lacked a universal method for building them. Therefore, we cannot provide boot images of _non-GKI_ kernels. + +Prerequisites: open source bootable kernel. + +### Hook method + +1. **KPROBES hook:** + + - Default hook method on GKI kernels. + - Requires `# CONFIG_KSU_MANUAL_HOOK is not set` & `CONFIG_KPROBES=y` + - Used for Loadable Kernel Module (LKM). + +2. **Manual hook:** + + + + - Requires `CONFIG_KSU_MANUAL_HOOK=y` + - Requires [`guide/how-to-integrate.md`](guide/how-to-integrate.md) + - Requires [https://github.com/~](https://github.com/tiann/KernelSU/blob/main/website/docs/guide/how-to-integrate-for-non-gki.md#manually-modify-the-kernel-source) + +3. **Tracepoint Hook:** + + - Hook method introduced since SukiSU commit [49b01aad](https://github.com/SukiSU-Ultra/SukiSU-Ultra/commit/49b01aad74bcca6dba5a8a2e053bb54b648eb124) + - Requires `CONFIG_KSU_TRACEPOINT_HOOK=y` + - Requires [`guide/tracepoint-hook.md`](tracepoint-hook.md) + + + +If you're able to build a bootable kernel, there are two ways to integrate KernelSU into the kernel source code: + +1. Automatically with `kprobe` +2. Manually + +## Integrate with kprobe + +Applicable: + +- _GKI_ kernel + +Not applicable: + +- _non-GKI_ kernel + +KernelSU uses kprobe to do kernel hooks. If kprobe runs well in your kernel, it's recommended to use it this way. + +Please refer to this document [https://github.com/~](https://github.com/tiann/KernelSU/blob/main/website/docs/guide/how-to-integrate-for-non-gki.md#integrate-with-kprobe). Although it is titled “for _non-GKI_,” it only applies to _GKI_. + +The execution command for the step that adds KernelSU to your kernel source tree is replaced with: + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main +``` + +## Manually modify the kernel source + +Applicable: + +- GKI kernel +- non-GKI kernel + +Please refer to this document [https://github.com/~ (Integrate for non-GKI)](https://github.com/tiann/KernelSU/blob/main/website/docs/guide/how-to-integrate-for-non-gki.md#manually-modify-the-kernel-source) and [https://github.com/~ (Build for GKI)](https://kernelsu.org/zh_CN/guide/how-to-build.html) to integrate manually, although first link is titled “for non-GKI,” it also applies to GKI. It can work on them both. + +There is another way to integrate but still work in the process. + + + +Run command for the step that adds KernelSU(SukiSU) to your kernel source tree is replaced with: + +### GKI kernel + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main +``` + +### non-GKI kernel + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s nongki +``` + +### GKI / non-GKI kernel with susfs (experiment) + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-{{branch}} +``` + +Branch: + +- `main` (susfs-main) +- `test` (susfs-test) +- version (for example: susfs-1.5.7, you should check the [branches](https://github.com/SukiSU-Ultra/SukiSU-Ultra/branches)) diff --git a/docs/guide/installation.md b/docs/guide/installation.md new file mode 100644 index 0000000..dccbf9d --- /dev/null +++ b/docs/guide/installation.md @@ -0,0 +1,34 @@ +# Installation + +You can go to [KernelSU Documentation - Installation](https://kernelsu.org/guide/installation.html) for a reference on how to install it, here are just additional instructions. + +## Installation by loading the Loadable Kernel Module(LKM) + +See [KernelSU Documentation - LKM Installation](https://kernelsu.org/guide/installation.html#lkm-installation) + +Beginning with **Android™** (trademark meaning licensed Google Mobile Services) 12, devices shipping with kernel version 5.10 or higher must ship with the GKI kernel. You may be able to use LKM mode. + +## Installation by installing the kernel + +See [KernelSU Documentation - GKI mode Installation](https://kernelsu.org/guide/installation.html#gki-mode-installation) + +We provide pre-built kernels for you to use: + +- [ShirkNeko flavor kernel](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS) (add ZRAM compression algorithm patch, susfs, KPM. Works on many devices.) +- [MiRinFork flavored kernel](https://github.com/MiRinFork/GKI_SukiSU_SUSFS) (adds susfs, KPM. Closest kernel to GKI, works on most devices.) + +Although some devices can be installed using LKM mode, they cannot be installed on the device by using the GKI kernel; therefore, the kernel needs to be modified manually to compile it. For example: + +- OPPO(OnePlus, REALME) +- Meizu + +Also, we provide pre-built kernels for your OnePlus device to use: + +- [ShirkNeko/Action_OnePlus_MKSU_SUSFS](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS) (add ZRAM compression algorithm patch, susfs, KPM.) + +Using the link above, Fork into GitHub Action, fill in the build parameters, compile, and finally flush in the zip with the AnyKernel3 suffix. + +> [!Note] +> +> - You only need to fill in the first two parts of the version number, e.g. `5.10`, `6.1`... +> - Make sure you know the processor designation, kernel version, etc. before you use it. diff --git a/docs/guide/tracepoint-hook.md b/docs/guide/tracepoint-hook.md new file mode 100644 index 0000000..af5fa72 --- /dev/null +++ b/docs/guide/tracepoint-hook.md @@ -0,0 +1,239 @@ +# Tracepoint Hook Integration + +## Introduction + +Since commit [49b01aad](https://github.com/SukiSU-Ultra/SukiSU-Ultra/commit/49b01aad74bcca6dba5a8a2e053bb54b648eb124), SukiSU has introduced Tracepoint Hook + +This Hook theoretically has lower performance overhead compared to Kprobes Hook, but is inferior to Manual Hook / Syscall Hook + +> [!NOTE] +> This tutorial references the syscall hook v1.4 version from [backslashxx/KernelSU#5](https://github.com/backslashxx/KernelSU/issues/5), as well as the original KernelSU's [Manual Hook](https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source) + +## Guide + +### execve Hook (`exec.c`) + +Generally need to modify the `do_execve` and `compat_do_execve` methods in `fs/exec.c` + +```patch +--- a/fs/exec.c ++++ b/fs/exec.c +@@ -78,6 +78,10 @@ + #include + #endif + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + EXPORT_TRACEPOINT_SYMBOL_GPL(task_rename); + + static int bprm_creds_from_file(struct linux_binprm *bprm); +@@ -2037,6 +2041,9 @@ static int do_execve(struct filename *filename, + { + struct user_arg_ptr argv = { .ptr.native = __argv }; + struct user_arg_ptr envp = { .ptr.native = __envp }; ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_execveat_hook((int *)AT_FDCWD, &filename, &argv, &envp, 0); ++#endif + return do_execveat_common(AT_FDCWD, filename, argv, envp, 0); + } + +@@ -2064,6 +2071,9 @@ static int compat_do_execve(struct filename *filename, + .is_compat = true, + .ptr.compat = __envp, + }; ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_execveat_hook((int *)AT_FDCWD, &filename, &argv, &envp, 0); // 32-bit su and 32-on-64 support ++#endif + return do_execveat_common(AT_FDCWD, filename, argv, envp, 0); + } +``` + +### faccessat Hook (`open.c`) + +Generally need to modify the `do_faccessat` method in `/fs/open.c` + +```patch +--- a/fs/open.c ++++ b/fs/open.c +@@ -37,6 +37,10 @@ + #include "internal.h" + #include + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + int do_truncate(struct user_namespace *mnt_userns, struct dentry *dentry, + loff_t length, unsigned int time_attrs, struct file *filp) + { +@@ -468,6 +472,9 @@ static long do_faccessat(int dfd, const char __user *filename, int mode, int fla + + SYSCALL_DEFINE3(faccessat, int, dfd, const char __user *, filename, int, mode) + { ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_faccessat_hook(&dfd, &filename, &mode, NULL); ++#endif + return do_faccessat(dfd, filename, mode, 0); + } +``` + +If there's no `do_faccessat` method, you can find the `faccessat` SYSCALL definition (for kernels earlier than 4.17) + +```patch +--- a/fs/open.c ++++ b/fs/open.c +@@ -31,6 +31,9 @@ + #include + #include + #include ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif + + #include "internal.h" + +@@ -369,6 +372,9 @@ SYSCALL_DEFINE3(faccessat, int, dfd, const char __user *, filename, int, mode) + int res; + unsigned int lookup_flags = LOOKUP_FOLLOW; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_faccessat_hook(&dfd, &filename, &mode, NULL); ++#endif + if (mode & ~S_IRWXO) /* where's F_OK, X_OK, W_OK, R_OK? */ + return -EINVAL; +``` + +### sys_read Hook (`read_write.c`) + +Need to modify the `sys_read` method in `fs/read_write.c` (4.19 and above) + +```patch +--- a/fs/read_write.c ++++ b/fs/read_write.c +@@ -25,6 +25,10 @@ + #include + #include + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + const struct file_operations generic_ro_fops = { + .llseek = generic_file_llseek, + .read_iter = generic_file_read_iter, +@@ -630,6 +634,9 @@ ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count) + + SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) + { ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_sys_read_hook(fd, &buf, &count); ++#endif + return ksys_read(fd, buf, count); + } +``` + +Or the `read` SYSCALL definition (4.14 and below) + +```patch +--- a/fs/read_write.c ++++ b/fs/read_write.c +@@ -25,6 +25,11 @@ + #include + #include + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ ++ + const struct file_operations generic_ro_fops = { + .llseek = generic_file_llseek, + .read_iter = generic_file_read_iter, +@@ -575,6 +580,9 @@ SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) + + if (f.file) { + loff_t pos = file_pos_read(f.file); ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_sys_read_hook(fd, &buf, &count); ++#endif + ret = vfs_read(f.file, buf, count, &pos); + if (ret >= 0) + file_pos_write(f.file, pos); +``` + +### fstatat Hook (`stat.c`) + +Need to modify the `newfstatat` SYSCALL definition in `stat.c` + +If 32-bit support is needed, also need to modify the `statat64` SYSCALL definition + +```patch +--- a/fs/stat.c ++++ b/fs/stat.c +@@ -24,6 +24,10 @@ + #include "internal.h" + #include "mount.h" + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + /** + * generic_fillattr - Fill in the basic attributes from the inode struct + * @mnt_userns: user namespace of the mount the inode was found from +@@ -408,6 +412,10 @@ SYSCALL_DEFINE4(newfstatat, int, dfd, const char __user *, filename, + struct kstat stat; + int error; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_stat_hook(&dfd, &filename, &flag); ++#endif ++ + error = vfs_fstatat(dfd, filename, &stat, flag); + if (error) + return error; +@@ -559,6 +567,10 @@ SYSCALL_DEFINE4(fstatat64, int, dfd, const char __user *, filename, + struct kstat stat; + int error; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_stat_hook(&dfd, &filename, &flag); /* 32-bit su support */ ++#endif ++ + error = vfs_fstatat(dfd, filename, &stat, flag); + if (error) + return error; +``` + +### input Hook (`input.c`, for entering KSU built-in security mode) + +Need to modify the `input_event` method in `drivers/input/input.c`, not `input_handle_event` + +```patch +--- a/drivers/input/input.c ++++ b/drivers/input/input.c +@@ -26,6 +26,10 @@ + #include "input-compat.h" + #include "input-poller.h" + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../../drivers/kernelsu/ksu_trace.h> ++#endif ++ + MODULE_AUTHOR("Vojtech Pavlik "); + MODULE_DESCRIPTION("Input core"); + MODULE_LICENSE("GPL"); +@@ -451,6 +455,10 @@ void input_event(struct input_dev *dev, + { + unsigned long flags; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_input_hook(&type, &code, &value); ++#endif ++ + if (is_event_supported(type, dev->evbit, EV_MAX)) { + + spin_lock_irqsave(&dev->event_lock, flags); +``` diff --git a/docs/ja/README.md b/docs/ja/README.md new file mode 100644 index 0000000..d70495d --- /dev/null +++ b/docs/ja/README.md @@ -0,0 +1,153 @@ +# SukiSU Ultra +sukisu logo + + +[English](../README.md) | [简体中文](../zh/README.md) | **日本語** | [Türkçe](../tr/README.md) + +[KernelSU](https://github.com/tiann/KernelSU) をベースとした Android デバイスの root ソリューション + +**試験中なビルドです!自己責任で使用してください!**
+このソリューションは [KernelSU](https://github.com/tiann/KernelSU) に基づいていますが、試験中なビルドです。 + +> これは非公式なフォークです。すべての権利は [@tiann](https://github.com/tiann) に帰属します。 +> +> ただし、将来的には KSU とは別に管理されるブランチとなる予定です。 + +## 追加する方法 + +メインブランチを使用 (非 GKI のデバイスのビルドは非対応) (susfs を手動で統合が必要) + +``` +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main +``` + +非 GKI のデバイスに対応するブランチを使用 (susfs を手動で統合が必要) + +``` +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s nongki +``` + +## 統合された susfs の使い方 + +1. susfs-main または他の susfs-\* ブランチを直接で使用、susfs の統合は不要 (非 GKI デバイスのビルドに対応) + +``` +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-main +``` + +## フックの方式 + +- この方式は (https://github.com/rsuntk/KernelSU) のフック方式を参照してください。 + +1. **KPROBES でフック:** + + - 読み込み可能なカーネルモジュールの場合 (LKM) + - GKI カーネルのデフォルトとなるフック方式 + - `CONFIG_KPROBES=y` が必要です + +2. **手動でフック:** + - 標準の KernelSU フック: https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source + - backslashxx syscall フック: https://github.com/backslashxx/KernelSU/issues/5 + - 非 GKI カーネル用のデフォルトフック方式 + - `CONFIG_KSU_MANUAL_HOOK=y` が必要です + +## KPM に対応 + +- KernelPatch に基づいて重複した KSU の機能を削除、KPM の対応を維持させています。 +- KPM 機能の整合性を確保するために、APatch の互換機能を更に向上させる予定です。 + +オープンソースアドレス: https://github.com/ShirkNeko/SukiSU_KernelPatch_patch + +KPM テンプレートのアドレス: https://github.com/udochina/KPM-Build-Anywhere + +> [!Note] +> +> 1. `CONFIG_KPM=y` が必要です。 +> 2. 非 GKI デバイスには `CONFIG_KALLSYMS=y` と `CONFIG_KALLSYMS_ALL=y` も必要です。 +> 3. いくつかのカーネル `4.19` およびそれ以降のソースコードでは、 `4.19` からバックポートされた `set_memory.h` ヘッダーファイルも必要です。 + +## ROOT を保持した状態でのシステムアップデートの方法 + +- 始めに OTA 後すぐに再起動せずにマネージャーのカーネルのフラッシュ、パッチのインターフェースを開いて`GKI/非 GKI のインストール`を見つけます。フラッシュする AnyKernel3 の zip ファイルを選択し、フラッシュする実行中のスロットと逆のスロットを選択後に再起動をして GKI モードの更新が保持できます (この方法はすべての非 GKI のデバイスが対応している訳ではないので、自分でお試しください。これは非 GKI のデバイスで TWRP を使用する最も安全な方法です)。 +- または LKM モードを使用して未使用のスロットにインストールします (OTA 後)。 + +## 互換性の状態 + +- KernelSU (v1.0.0 より前) は Android GKI 2.0 のデバイス (カーネル 5.10 以降) を公式に対応しています。 + +- 古いカーネル (4.4 以降) も互換性がありますが、カーネルを手動で再ビルドする必要があります。 + +- KernelSU は追加のリバースポートを通じて 3.x カーネル (3.4-3.18) で対応可能です。 + +- 現在 `arm64-v8a`, `armeabi-v7a (bare)` および一部の `X86_64` に対応しています。 + +## その他のリンク + +**マネージャーの翻訳を行う場合** https://crowdin.com/project/SukiSU-Ultra + +- [その他パッチ済み GKI](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS) ZRAM パッチ、KPM、susfs が含まれています... +- [パッチの少ない GKI](https://github.com/MiRinFork/GKI_SukiSU_SUSFS/releases) susfs のみ +- [OnePlus](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS) + +## 使い方 + +### Universal GKI + +**すべて**参照してください https://kernelsu.org/ja_JP/guide/installation.html + +> [!Note] +> +> 1. Xiaomi、Redmi、Samsung などの GKI 2.0 を搭載したデバイス向け (Meizu、OnePlus、Zenith、Oppo などカーネルが変更されているメーカーを除く) +> 2. GKI のビルドは[その他のリンク](#その他のリンク)から入手できます。デバイスのカーネルバージョンを確認してください。ダウンロード後に TWRP またはカーネルフラッシュツールを使用して AnyKernel3 の接頭辞を持つ zip ファイルをフラッシュしてください。Pixel のユーザーは、パッチの少ない GKI を使用する必要があります。 +> 3. 接頭辞のない .zip アーカイブは圧縮されていません。.gz の接頭辞は Tenguet モデルで使用される圧縮になります。 + +### OnePlus + +1. `その他のリンク`の項目に記載されているリンクを開き、デバイス情報を使用してカスタマイズされたカーネルをビルドし、AnyKernel3 の接頭辞を持つ .zip ファイルをフラッシュします。 + +> [!Note] +> +> - 5.10、5.15、6.1、6.6 などのカーネルバージョンの最初の 2 文字のみを入力する必要があります。 +> - SoC のコードネームは自分で検索してください。通常は、数字がなく英語表記のみです。 +> - ブランチと構成ファイルは、OnePlus オープンソースカーネルリポジトリから見つけることができます。 + +## 機能 + +1. カーネルベースな `su` および root アクセスの管理。 +2. [OverlayFS](https://en.wikipedia.org/wiki/OverlayFS) モジュールシステムではなく、 5ec1cff 氏の [Magic Mount](https://github.com/5ec1cff/KernelSU) に基づいています。 +3. [アプリプロファイル](https://kernelsu.org/guide/app-profile.html): root 権限をケージ内にロックします。 +4. 非 GKI / GKI 1.0 の対応を復活 +5. その他のカスタマイズ +6. KPM カーネルモジュールに対応 + +## トラブルシューティング + +1. KernelSU Manager のアンインストールが停止してしまう → com.sony.playmemories.mobile のアプリをアンインストールしてください。 + +## ライセンス + +- 「kernel」のディレクトリ内のファイルは [GPL-2.0-only](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) のライセンスに基づいています。 +- アニメキャラクター画像とスタンプを含むこれらのファイルの `ic_launcher(?!.*alt.*).*` は[怡子曰曰](https://space.bilibili.com/10545509)によって著作権保護されており、画像の Brand Intellectual Property は[明风 OuO](https://space.bilibili.com/274939213)によって所有され、ベクター化は @MiRinChan によって行われています。 これらのファイルを使用する前に、[Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt)を遵守することに加えて、アートコンテンツを使用するために前の 2 人の作者から許可を得る必要があります。 +- 上記のファイルまたはディレクトリを除き、その他のすべての部分は[GPL-3.0 以降](https://www.gnu.org/licenses/gpl-3.0.html)です。 + +## スポンサーシップの一覧 + +- [Ktouls](https://github.com/Ktouls) 応援してくれてありがとう +- [zaoqi123](https://github.com/zaoqi123) ミルクティーを買ってあげるのも良い考えですね +- [wswzgdg](https://github.com/wswzgdg) このプロジェクトにご支援いただき、ありがとうございます +- [yspbwx2010](https://github.com/yspbwx2010) ありがとうございます +- [DARKWWEE](https://github.com/DARKWWEE) ラオスから 100 USDT の支援に感謝します +- [Saksham Singla](https://github.com/TypeFlu) ウェブサイトの提供とメンテナンス +- [OukaroMF](https://github.com/OukaroMF) ウェブサイトのドメインと寄付 + +## 貢献者 + +- [KernelSU](https://github.com/tiann/KernelSU): オリジナルのプロジェクト +- [MKSU](https://github.com/5ec1cff/KernelSU): 使用しているプロジェクト +- [RKSU](https://github.com/rsuntk/KernelsU): このプロジェクトのカーネルを使用した非 GKI デバイスのサポートの再導入 +- [susfs](https://gitlab.com/simonpunk/susfs4ksu): susfs ファイルシステムの使用 +- [KernelSU](https://git.zx2c4.com/kernel-assisted-superuser/about/): KernelSU の概念化 +- [Magisk](https://github.com/topjohnwu/Magisk): パワフルな root ユーティリティ +- [genuine](https://github.com/brevent/genuine/): APK v2 署名認証 +- [Diamorphine](https://github.com/m0nad/Diamorphine): いくつかの root キットユーティリティ +- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch はカーネルモジュールの APatch 実装の重要な部分での活用 diff --git a/docs/ja/SukiSU-mini.svg b/docs/ja/SukiSU-mini.svg new file mode 100644 index 0000000..1c7d20f --- /dev/null +++ b/docs/ja/SukiSU-mini.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/tr/README.md b/docs/tr/README.md new file mode 100644 index 0000000..94b55a7 --- /dev/null +++ b/docs/tr/README.md @@ -0,0 +1,151 @@ +# SukiSU Ultra +sukisu logo + + +[English](../README.md) | [简体中文](../zh/README.md) | [日本語](../ja/README.md) | **Türkçe** + +[KernelSU](https://github.com/tiann/KernelSU) tabanlı Android cihaz root çözümü + +**Deneysel! Kullanım riski size aittir!** + +> Bu resmi olmayan bir daldır, tüm hakları saklıdır [@tiann](https://github.com/tiann) +> +> Ancak, gelecekte ayrı bir KSU dalı olarak devam edeceğiz + +## Nasıl Eklenir + +Çekirdek kaynak kodunun kök dizininde aşağıdaki komutları çalıştırın: + +Ana dalı kullanın (GKI olmayan cihazlar için desteklenmez) + +``` +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main +``` + +GKI olmayan cihazları destekleyen dalı kullanın + +``` +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s nongki +``` + +## susfs Nasıl Entegre Edilir + +1. Doğrudan susfs-main veya susfs-\* dalını kullanın, susfs entegrasyonuna gerek yok + +``` +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-main +``` + +## Kanca Yöntemleri + +- Bu bölüm [rsuntk\'nin kanca yöntemlerinden](https://github.com/rsuntk/KernelSU) alıntılanmıştır + +1. **KPROBES Kancası:** + + - Yüklenebilir çekirdek modülleri (LKM) için kullanılır + - GKI 2.0 çekirdeğinin varsayılan kanca yöntemi + - `CONFIG_KPROBES=y` gerektirir + +2. **Manuel Kanca:** + - Standart KernelSU kancası: https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source + - backslashxx\'nin syscall manuel kancası: https://github.com/backslashxx/KernelSU/issues/5 + - GKI olmayan çekirdeğin varsayılan kanca yöntemi + - `CONFIG_KSU_MANUAL_HOOK=y` gerektirir + +## KPM Desteği + +- KernelPatch tabanlı olarak KSU ile çakışan işlevleri kaldırdık ve yalnızca KPM desteğini koruduk +- APatch ile daha fazla uyumlu fonksiyon ekleyerek KPM işlevlerinin bütünlüğünü sağlayacağız + +Kaynak kodu: https://github.com/ShirkNeko/SukiSU_KernelPatch_patch + +KPM şablonu: https://github.com/udochina/KPM-Build-Anywhere + +> [!Note] +> +> 1. `CONFIG_KPM=y` gerektirir +> 2. GKI olmayan cihazlar ayrıca `CONFIG_KALLSYMS=y` ve `CONFIG_KALLSYMS_ALL=y` gerektirir +> 3. Bazı çekirdek `4.19` altı kaynak kodları, `4.19`dan geri taşınan başlık dosyası `set_memory.h` gerektirir + +## Sistem Güncellemesini Yaparak ROOT\'u Koruma + +- OTA\'dan sonra hemen yeniden başlatmayın, yöneticiye girin ve çekirdek yazma/onarma arayüzüne gidin, `GKI/non_GKI yükleme` seçeneğini bulun ve Anykernel3 çekirdek sıkıştırma dosyasını seçin, şu anda sistemin çalıştığı yuva ile zıt yuvaya yazın ve yeniden başlatın, böylece GKI modu güncellemesini koruyabilirsiniz (şu anda tüm GKI olmayan cihazlar bu yöntemi desteklemiyor, lütfen kendiniz deneyin. GKI olmayan cihazlar için TWRP kullanmak en güvenlidir) +- Veya kullanılmayan yuvaya LKM modunu kullanarak yükleyin (OTA\'dan sonra) + +## Uyumluluk Durumu + +- KernelSU (v1.0.0 öncesi sürümler) resmi olarak Android GKI 2.0 cihazlarını destekler (çekirdek 5.10+) + +- Eski çekirdekler (4.4+) de uyumludur, ancak çekirdeği manuel olarak oluşturmanız gerekir + +- Daha fazla geri taşımayla KernelSU, 3.x çekirdeğini (3.4-3.18) destekleyebilir + +- Şu anda `arm64-v8a`, `armeabi-v7a (bare)` ve bazı `X86_64` desteklenmektedir + +## Daha Fazla Bağlantı + +SukiSU ve susfs tabanlı derlenen projeler + +- [GKI](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS) +- [OnePlus](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS) + +## Kullanım Yöntemi + +### Evrensel GKI + +Lütfen **tümünü** https://kernelsu.org/zh_CN/guide/installation.html adresinden inceleyin + +> [!Note] +> +> 1. Xiaomi, Redmi, Samsung gibi GKI 2.0 cihazlar için uygundur (Meizu, OnePlus, Realme ve Oppo gibi değiştirilmiş çekirdekli üreticiler hariç) +> 2. [Daha fazla bağlantı](#daha-fazla-bağlantı) bölümündeki GKI tabanlı projeleri bulun. Cihaz çekirdek sürümünü bulun. Ardından indirin ve TWRP veya çekirdek yazma aracı kullanarak AnyKernel3 soneki olan sıkıştırılmış paketi yazın +> 3. Genellikle sonek olmayan .zip sıkıştırılmış paketler sıkıştırılmamıştır, gz soneki olanlar ise Dimensity modelleri için kullanılan sıkıştırma yöntemidir + +### OnePlus + +1. Daha fazla bağlantı bölümündeki OnePlus projesini bulun ve kendiniz doldurun, ardından bulut derleme yapın ve AnyKernel3 soneki olan sıkıştırılmış paketi yazın + +> [!Note] +> +> - Çekirdek sürümü için yalnızca ilk iki haneyi doldurmanız yeterlidir, örneğin 5.10, 5.15, 6.1, 6.6 +> - İşlemci kod adını kendiniz arayın, genellikle tamamen İngilizce ve sayı içermeden oluşur +> - Dal ve yapılandırma dosyasını kendiniz OnePlus çekirdek kaynak kodundan doldurun + +## Özellikler + +1. Çekirdek tabanlı `su` ve root erişim yönetimi +2. 5ec1cff\'nin [Magic Mount](https://github.com/5ec1cff/KernelSU) tabanlı modül sistemi +3. [App Profile](https://kernelsu.org/guide/app-profile.html): root yetkilerini kafeste kilitleyin +4. GKI 2.0 olmayan çekirdekler için desteğin geri getirilmesi +5. Daha fazla özelleştirme özelliği +6. KPM çekirdek modülleri için destek + +## Lisans + +- `kernel` dizinindeki dosyalar [GPL-2.0-only](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) lisansı altındadır. +- Anime karakter ifadeleri içeren `ic_launcher(?!.*alt.*).*` dosyalarının görüntüleri [怡子曰曰](https://space.bilibili.com/10545509) tarafından telif hakkıyla korunmaktadır, görüntülerdeki Marka Fikri Mülkiyeti [明风 OuO](https://space.bilibili.com/274939213)'ye aittir ve vektörleştirme @MiRinChan tarafından yapılmıştır. Bu dosyaları kullanmadan önce, [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt) ile uyumlu olmanın yanı sıra, bu sanatsal içerikleri kullanmak için iki yazarın yetkilendirmesine de uymanız gerekir. +- Yukarıda belirtilen dosyalar veya dizinler hariç, diğer tüm parçalar [GPL-3.0 veya üzeri](https://www.gnu.org/licenses/gpl-3.0.html)'dir. + +## Afdian Bağlantısı + +- https://afdian.com/a/shirkneko + +## Sponsor Listesi + +- [Ktouls](https://github.com/Ktouls) Bana sağladığınız destek için çok teşekkür ederim +- [zaoqi123](https://github.com/zaoqi123) Bana sütlü çay ısmarlamanız da güzel +- [wswzgdg](https://github.com/wswzgdg) Bu projeye olan desteğiniz için çok teşekkür ederim +- [yspbwx2010](https://github.com/yspbwx2010) Çok teşekkür ederim +- [DARKWWEE](https://github.com/DARKWWEE) 100 USDT için teşekkürler + +## Katkıda Bulunanlar + +- [KernelSU](https://github.com/tiann/KernelSU): Orijinal proje +- [MKSU](https://github.com/5ec1cff/KernelSU): Kullanılan proje +- [RKSU](https://github.com/rsuntk/KernelsU): GKI olmayan cihazlar için destek sağlayan proje +- [susfs4ksu](https://gitlab.com/simonpunk/susfs4ksu): Kullanılan susfs dosya sistemi +- [kernel-assisted-superuser](https://git.zx2c4.com/kernel-assisted-superuser/about/): KernelSU fikri +- [Magisk](https://github.com/topjohnwu/Magisk): Güçlü root aracı +- [genuine](https://github.com/brevent/genuine/): APK v2 imza doğrulama +- [Diamorphine](https://github.com/m0nad/Diamorphine): Bazı rootkit becerileri +- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch, APatch\'in çekirdek modüllerini uygulamak için kritik bir parçadır diff --git a/docs/tr/SukiSU-mini.svg b/docs/tr/SukiSU-mini.svg new file mode 100644 index 0000000..1c7d20f --- /dev/null +++ b/docs/tr/SukiSU-mini.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/zakomonochrome-128.svg b/docs/zakomonochrome-128.svg new file mode 100644 index 0000000..8d2cfc2 --- /dev/null +++ b/docs/zakomonochrome-128.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + diff --git a/docs/zh/README.md b/docs/zh/README.md new file mode 100644 index 0000000..b82a863 --- /dev/null +++ b/docs/zh/README.md @@ -0,0 +1,101 @@ +# SukiSU Ultra +sukisu logo + + +[English](../README.md) | **简体中文** | [日本語](../ja/README.md) | [Türkçe](../tr/README.md) + +一个 Android 上基于内核的 root 方案,由 [`tiann/KernelSU`](https://github.com/tiann/KernelSU) 分叉而来,添加了一些有趣的变更。 + +[![最新发行](https://img.shields.io/github/v/release/SukiSU-Ultra/SukiSU-Ultra?label=Release&logo=github)](https://github.com/tiann/KernelSU/releases/latest) +[![频道](https://img.shields.io/badge/Follow-Telegram-blue.svg?logo=telegram)](https://t.me/Sukiksu) +[![协议: GPL v2](https://img.shields.io/badge/License-GPL%20v2-orange.svg?logo=gnu)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) +[![GitHub 协议](https://img.shields.io/github/license/tiann/KernelSU?logo=gnu)](/LICENSE) + +## 特性 + +1. 基于内核的 `su` 和权限管理。 +2. 基于 [Magic Mount](https://github.com/5ec1cff/KernelSU) 的模块系统。 +3. [App Profile](https://kernelsu.org/zh_CN/guide/app-profile.html): 把 Root 权限关进笼子里。 +4. 支持 non-GKI 与 GKI 1.0。 +5. KPM 支持 +6. 可调整管理器外观,可自定义 susfs 配置。 + +## 兼容状态 + +- KernelSU 官方支持 GKI 2.0 的设备(内核版本 5.10 以上)。 + +- 旧内核也是兼容的(最低 4.14+),不过需要自己编译内核。 + +- 通过更多的反向移植,KernelSU 可以支持 3.x 内核(3.4-3.18)。 + +- 目前支持架构 : `arm64-v8a`、`armeabi-v7a (bare)`、`X86_64`。 + +## 安装指导 + +查看 [`guide/installation.md`](guide/installation.md) + +## 集成指导 + +查看 [`guide/how-to-integrate.md`](guide/how-to-integrate.md) + +## 参与翻译 + +要将 SukiSU 翻译成您的语言,或完善现有的翻译,请使用 [Crowdin](https://crowdin.com/project/SukiSU-Ultra). + +## KPM 支持 + +- 基于 KernelPatch 开发,移除了与 KernelSU 重复的功能。 +- 正在进行(WIP):通过集成附加功能来扩展 APatch 兼容性,以确保跨不同实现的兼容性。 + +**开源仓库**: [https://github.com/ShirkNeko/SukiSU_KernelPatch_patch](https://github.com/ShirkNeko/SukiSU_KernelPatch_patch) + +**KPM 模板**: [https://github.com/udochina/KPM-Build-Anywhere](https://github.com/udochina/KPM-Build-Anywhere) + +> [!Note] +> +> 1. 需要 `CONFIG_KPM=y` +> 2. Non-GKI 设备需要 `CONFIG_KALLSYMS=y` and `CONFIG_KALLSYMS_ALL=y` +> 3. 对于低于 `4.19` 的内核,需要从 `4.19` 的 `set_memory.h` 进行反向移植。 + +## 故障排除 + +1. 卸载管理器后系统卡住? + 卸载 _com.sony.playmemories.mobile_ + +## 许可证 + +- 目录 `kernel` 下所有文件为 [GPL-2.0-only](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)。 +- 有动漫人物图片表情包的这些文件 `ic_launcher(?!.*alt.*).*` 的图像版权为[怡子曰曰](https://space.bilibili.com/10545509)所有,图像中的知识产权由[明风 OuO](https://space.bilibili.com/274939213)所有,矢量化由 @MiRinChan 完成,在使用这些文件之前,除了必须遵守 [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt) 以外,还需要遵守向前两者索要使用这些艺术内容的授权。 +- 除上述文件及目录的其他部分均为 [GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html)。 + +## 赞助 + +- [ShirkNeko](https://afdian.com/a/shirkneko) (SukiSU 主要维护者) +- [weishu](https://github.com/sponsors/tiann) (KernelSU 作者) + +## ShirkNeko 的赞助列表 + +- [Ktouls](https://github.com/Ktouls) 非常感谢你给我带来的支持 +- [zaoqi123](https://github.com/zaoqi123) 请我喝奶茶也不错 +- [wswzgdg](https://github.com/wswzgdg) 非常感谢对此项目的支持 +- [yspbwx2010](https://github.com/yspbwx2010) 非常感谢 +- [DARKWWEE](https://github.com/DARKWWEE) 感谢老哥的 100 USDT +- [Saksham Singla](https://github.com/TypeFlu) 网站的提供以及维护 +- [OukaroMF](https://github.com/OukaroMF) 网站域名捐赠 + +## 鸣谢 + +- [KernelSU](https://github.com/tiann/KernelSU): 上游 +- [MKSU](https://github.com/5ec1cff/KernelSU): 魔法坐骑支持 +- [RKSU](https://github.com/rsuntk/KernelsU): non-GKI 支持 +- [susfs](https://gitlab.com/simonpunk/susfs4ksu): 隐藏内核补丁以及用户空间模组的 KernelSU 附件 +- [KernelPatch](https://github.com/bmax121/KernelPatch): KernelPatch 是内核模块 APatch 实现的关键部分 + +
+KernelSU 的鸣谢 + +- [kernel-assisted-superuser](https://git.zx2c4.com/kernel-assisted-superuser/about/):KernelSU 的灵感。 +- [Magisk](https://github.com/topjohnwu/Magisk):强大的 root 工具箱。 +- [genuine](https://github.com/brevent/genuine/):apk v2 签名验证。 +- [Diamorphine](https://github.com/m0nad/Diamorphine):一些 rootkit 技巧。 +
diff --git a/docs/zh/SukiSU-mini.svg b/docs/zh/SukiSU-mini.svg new file mode 100644 index 0000000..1c7d20f --- /dev/null +++ b/docs/zh/SukiSU-mini.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/zh/SukiSU.svg b/docs/zh/SukiSU.svg new file mode 100644 index 0000000..559a5d5 --- /dev/null +++ b/docs/zh/SukiSU.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/zh/guide/how-to-integrate.md b/docs/zh/guide/how-to-integrate.md new file mode 100644 index 0000000..38d6f10 --- /dev/null +++ b/docs/zh/guide/how-to-integrate.md @@ -0,0 +1,97 @@ +# 集成指导 + +SukiSU 可以集成到 GKI 和 non-GKI 内核中,并且已反向移植到 4.14 版本。 + + + +有些 OEM 定制可能导致多达 50% 的内核代码超出内核树代码,而非来自上游 Linux 内核或 ACK。因此,non-GKI 内核的定制特性导致了严重的内核碎片化,而且我们缺乏构建它们的通用方法。因此,我们无法提供 non-GKI 内核的启动映像。 + +前提条件:开源的、可启动的内核。 + +## Hook 方法 + +1. **KPROBES hook:** + + - GKI kernels 的默认 hook 方法。 + - 需要 `# CONFIG_KSU_MANUAL_HOOK is not set`(未设定) & `CONFIG_KPROBES=y` + - 用作可加载的内核模块 (LKM). + +2. **Manual hook:** + + + + - 需要 `CONFIG_KSU_MANUAL_HOOK=y` + - 需要 [`guide/how-to-integrate.md`](how-to-integrate.md) + - 需要 [https://github.com/~](https://github.com/tiann/KernelSU/blob/main/website/docs/guide/how-to-integrate-for-non-gki.md#manually-modify-the-kernel-source) + +3. **Tracepoint Hook:** + + - 自 SukiSU commit [49b01aad](https://github.com/SukiSU-Ultra/SukiSU-Ultra/commit/49b01aad74bcca6dba5a8a2e053bb54b648eb124) 引入的 hook 方法 + - 需要 `CONFIG_KSU_TRACEPOINT_HOOK=y` + - 需要 [`guide/tracepoint-hook.md`](tracepoint-hook.md) + + + +如果您能够构建可启动内核,有两种方法可以将 KernelSU 集成到内核源代码中: + +1. 使用 `kprobe` 自动集成 +2. 手动集成 + +## 与 kprobe 集成 + +适用: + +- GKI 内核 + +不适用: + +- non-GKI 内核 + +KernelSU 使用 kprobe 机制来做内核的相关 hook,如果 _kprobe_ 可以在你编译的内核中正常运行,那么推荐用这个方法来集成。 + +请参阅此文档 [https://github.com/~](https://github.com/tiann/KernelSU/blob/main/website/docs/guide/how-to-integrate-for-non-gki.md#integrate-with-kprobe)。虽然标题为“适用于 non-GKI”,但仅适用于 GKI。 + +替换 KernelSU 添加到内核源代码树的步骤的执行命令为: + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main +``` + +## 手动修改内核源代码 + +适用: + +- GKI 内核 +- non-GKI 内核 + +请参考此文档 [https://github.com/~ (non-GKI 内核集成)](https://github.com/tiann/KernelSU/blob/main/website/docs/guide/how-to-integrate-for-non-gki.md#manually-modify-the-kernel-source) 和 [https://github.com/~ (GKI 内核构建)](https://kernelsu.org/zh_CN/guide/how-to-build.html) 进行手动集成。虽然第一个链接的标题是“适用于 non-GKI”,但它也适用于 GKI。两者都可以正常工作。 + +还有另一种集成方法,但是仍在开发中。 + + + +将 KernelSU(SukiSU)添加到内核源代码树的步骤的运行命令将被替换为: + +### GKI 内核 + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s main +``` + +### non-GKI 内核 + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s nongki +``` + +### 带有 susfs 的 GKI / non-GKI 内核(实验) + +```sh +curl -LSs "https://raw.githubusercontent.com/SukiSU-Ultra/SukiSU-Ultra/main/kernel/setup.sh" | bash -s susfs-{{branch}} +``` + +分支: + +- `main` (susfs-main) +- `test` (susfs-test) +- 版本号 (例如: susfs-1.5.7, 你需要在 [分支](https://github.com/SukiSU-Ultra/SukiSU-Ultra/branches) 里找到它) diff --git a/docs/zh/guide/installation.md b/docs/zh/guide/installation.md new file mode 100644 index 0000000..4de4bb6 --- /dev/null +++ b/docs/zh/guide/installation.md @@ -0,0 +1,34 @@ +# 安装指导 + +您可以前往 [KernelSU 文档 - 安装](https://kernelsu.org/guide/installation.html) 获取有关如何安装的参考,这里只是额外的说明。 + +## 通过加载可加载内核模块 (LKM) 进行安装 + +请参阅 [KernelSU 文档 - LKM 安装](https://kernelsu.org/guide/installation.html#lkm-installation) + +从 **Android™**(商标,意为获得 Google 移动服务的许可)12 开始,搭载内核版本 5.10 或更高版本的设备必须搭载 GKI 内核。因此你或许可以使用 LKM 模式。 + +## 通过安装内核进行安装 + +请参阅 [KernelSU 文档 - GKI 模式安装](https://kernelsu.org/guide/installation.html#gki-mode-installation) + +我们提供预编译的内核供您使用: + +- [ShirkNeko 内核](https://github.com/ShirkNeko/GKI_KernelSU_SUSFS)(添加了 ZRAM 压缩算法补丁、susfs 文件和 KPM 文件。适用于很多设备。) +- [MiRinFork 内核](https://github.com/MiRinFork/GKI_SukiSU_SUSFS)(添加了 susfs 文件和 KPM 文件。最接近 GKI 的内核,适用于大多数设备。) + +虽然某些设备可以使用 LKM 模式安装,但无法使用 GKI 内核将其安装到设备上;因此,需要手动修改内核进行编译。例如: + +- 欧珀(一加、真我) +- 魅族 + +此外,我们还为您的 OnePlus 设备提供预编译的内核: + +- [ShirkNeko/Action_OnePlus_MKSU_SUSFS](https://github.com/ShirkNeko/Action_OnePlus_MKSU_SUSFS)(添加 ZRAM 压缩算法补丁、susfs 和 KPM。) + +使用上面的链接,Fork 到 GitHub Action,填写构建参数,进行编译,最后将 zip 文件以 AnyKernel3 后缀上传到 GitHub Action。 + +> [!Note] +> +> - 使用时,您只需填写版本号的前两部分,例如 `5.10`、`6.1`... +> - 使用前请确保您了解处理器名称、内核版本等信息。 diff --git a/docs/zh/guide/tracepoint-hook.md b/docs/zh/guide/tracepoint-hook.md new file mode 100644 index 0000000..7dde784 --- /dev/null +++ b/docs/zh/guide/tracepoint-hook.md @@ -0,0 +1,239 @@ +# Tracepoint Hook 集成 + +## 介绍 + +自 commit [49b01aad](https://github.com/SukiSU-Ultra/SukiSU-Ultra/commit/49b01aad74bcca6dba5a8a2e053bb54b648eb124) 起,SukiSU 引入了 Tracepoint Hook + +该 Hook 理论上相比于 Kprobes Hook,性能开销更小,但次于 Manual Hook / Syscall Hook + +> [!NOTE] +> 本教程参考了 [backslashxx/KernelSU#5](https://github.com/backslashxx/KernelSU/issues/5) 的 syscall hook v1.4 版本钩子,以及原版 KernelSU 的 [Manual Hook](https://kernelsu.org/guide/how-to-integrate-for-non-gki.html#manually-modify-the-kernel-source) + +## Guide + +### execve 钩子(`exec.c`) + +一般需要修改 `fs/exec.c` 的 `do_execve` 和 `compat_do_execve` 方法 + +```patch +--- a/fs/exec.c ++++ b/fs/exec.c +@@ -78,6 +78,10 @@ + #include + #endif + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + EXPORT_TRACEPOINT_SYMBOL_GPL(task_rename); + + static int bprm_creds_from_file(struct linux_binprm *bprm); +@@ -2037,6 +2041,9 @@ static int do_execve(struct filename *filename, + { + struct user_arg_ptr argv = { .ptr.native = __argv }; + struct user_arg_ptr envp = { .ptr.native = __envp }; ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_execveat_hook((int *)AT_FDCWD, &filename, &argv, &envp, 0); ++#endif + return do_execveat_common(AT_FDCWD, filename, argv, envp, 0); + } + +@@ -2064,6 +2071,9 @@ static int compat_do_execve(struct filename *filename, + .is_compat = true, + .ptr.compat = __envp, + }; ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_execveat_hook((int *)AT_FDCWD, &filename, &argv, &envp, 0)); // 32-bit su and 32-on-64 support ++#endif + return do_execveat_common(AT_FDCWD, filename, argv, envp, 0); + } +``` + +### faccessat 钩子 (`open.c`) + +一般需要修改 `/fs/open.c` 的 `do_faccessat` 方法 + +```patch +--- a/fs/open.c ++++ b/fs/open.c +@@ -37,6 +37,10 @@ + #include "internal.h" + #include + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + int do_truncate(struct user_namespace *mnt_userns, struct dentry *dentry, + loff_t length, unsigned int time_attrs, struct file *filp) + { +@@ -468,6 +472,9 @@ static long do_faccessat(int dfd, const char __user *filename, int mode, int fla + + SYSCALL_DEFINE3(faccessat, int, dfd, const char __user *, filename, int, mode) + { ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_faccessat_hook(&dfd, &filename, &mode, NULL); ++#endif + return do_faccessat(dfd, filename, mode, 0); + } +``` + +如果没有 `do_faccessat` 方法,可以找 `faccessat` 的 SYSCALL 定义(对于早于 4.17 的内核) + +```patch +--- a/fs/open.c ++++ b/fs/open.c +@@ -31,6 +31,9 @@ + #include + #include + #include ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif + + #include "internal.h" + +@@ -369,6 +372,9 @@ SYSCALL_DEFINE3(faccessat, int, dfd, const char __user *, filename, int, mode) + int res; + unsigned int lookup_flags = LOOKUP_FOLLOW; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_faccessat_hook(&dfd, &filename, &mode, NULL); ++#endif + if (mode & ~S_IRWXO) /* where's F_OK, X_OK, W_OK, R_OK? */ + return -EINVAL; +``` + +### sys_read 钩子 ( `read_write.c` ) + +需要修改 `fs/read_write.c` 的 `sys_read` 方法(4.19 及以上) + +```patch +--- a/fs/read_write.c ++++ b/fs/read_write.c +@@ -25,6 +25,10 @@ + #include + #include + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + const struct file_operations generic_ro_fops = { + .llseek = generic_file_llseek, + .read_iter = generic_file_read_iter, +@@ -630,6 +634,9 @@ ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count) + + SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) + { ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_sys_read_hook(fd, &buf, &count); ++#endif + return ksys_read(fd, buf, count); + } +``` + +或者是 `read` 的 SYSCALL 定义(4.14 及以下) + +```patch +--- a/fs/read_write.c ++++ b/fs/read_write.c +@@ -25,6 +25,11 @@ + #include + #include + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ ++ + const struct file_operations generic_ro_fops = { + .llseek = generic_file_llseek, + .read_iter = generic_file_read_iter, +@@ -575,6 +580,9 @@ SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) + + if (f.file) { + loff_t pos = file_pos_read(f.file); ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_sys_read_hook(fd, &buf, &count); ++#endif + ret = vfs_read(f.file, buf, count, &pos); + if (ret >= 0) + file_pos_write(f.file, pos); +``` + +### fstatat 钩子 ( `stat.c` ) + +需要修改 `stat.c` 的 `newfstatat` SYSCALL 定义 + +如果需要 32 位支持,还需要修改 `statat64` SYSCALL 定义 + +```patch +--- a/fs/stat.c ++++ b/fs/stat.c +@@ -24,6 +24,10 @@ + #include "internal.h" + #include "mount.h" + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../drivers/kernelsu/ksu_trace.h> ++#endif ++ + /** + * generic_fillattr - Fill in the basic attributes from the inode struct + * @mnt_userns: user namespace of the mount the inode was found from +@@ -408,6 +412,10 @@ SYSCALL_DEFINE4(newfstatat, int, dfd, const char __user *, filename, + struct kstat stat; + int error; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_stat_hook(&dfd, &filename, &flag); ++#endif ++ + error = vfs_fstatat(dfd, filename, &stat, flag); + if (error) + return error; +@@ -559,6 +567,10 @@ SYSCALL_DEFINE4(fstatat64, int, dfd, const char __user *, filename, + struct kstat stat; + int error; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_stat_hook(&dfd, &filename, &flag); /* 32-bit su support */ ++#endif ++ + error = vfs_fstatat(dfd, filename, &stat, flag); + if (error) + return error; +``` + +### input 钩子 (`input.c` ,用于进入KSU系的内置安全模式) + +需要修改 `drivers/input/input.c` 的 `input_event` 方法,而不是 `input_handle_event` + +```patch +--- a/drivers/input/input.c ++++ b/drivers/input/input.c +@@ -26,6 +26,10 @@ + #include "input-compat.h" + #include "input-poller.h" + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++#include <../../drivers/kernelsu/ksu_trace.h> ++#endif ++ + MODULE_AUTHOR("Vojtech Pavlik "); + MODULE_DESCRIPTION("Input core"); + MODULE_LICENSE("GPL"); +@@ -451,6 +455,10 @@ void input_event(struct input_dev *dev, + { + unsigned long flags; + ++#if defined(CONFIG_KSU) && defined(CONFIG_KSU_TRACEPOINT_HOOK) ++ trace_ksu_trace_input_hook(&type, &code, &value); ++#endif ++ + if (is_event_supported(type, dev->evbit, EV_MAX)) { + + spin_lock_irqsave(&dev->event_lock, flags); +``` \ No newline at end of file diff --git a/js/README.md b/js/README.md new file mode 100644 index 0000000..422377d --- /dev/null +++ b/js/README.md @@ -0,0 +1,121 @@ +# Library for KernelSU's module WebUI + +## Install + +```sh +yarn add kernelsu +``` + +## API + +### exec + +Spawns a **root** shell and runs a command within that shell, returning a Promise that resolves with the `stdout` and `stderr` outputs upon completion. + +- `command` `` The command to run, with space-separated arguments. +- `options` `` + - `cwd` - Current working directory of the child process. + - `env` - Environment key-value pairs. + +```javascript +import { exec } from 'kernelsu'; + +const { errno, stdout, stderr } = await exec('ls -l', { cwd: '/tmp' }); +if (errno === 0) { + // success + console.log(stdout); +} +``` + +### spawn + +Spawns a new process using the given `command` in **root** shell, with command-line arguments in `args`. If omitted, `args` defaults to an empty array. + +Returns a `ChildProcess` instance. Instances of `ChildProcess` represent spawned child processes. + +- `command` `` The command to run. +- `args` `` List of string arguments. +- `options` ``: + - `cwd` `` - Current working directory of the child process. + - `env` `` - Environment key-value pairs. + +Example of running `ls -lh /data`, capturing `stdout`, `stderr`, and the exit code: + +```javascript +import { spawn } from 'kernelsu'; + +const ls = spawn('ls', ['-lh', '/data']); + +ls.stdout.on('data', (data) => { + console.log(`stdout: ${data}`); +}); + +ls.stderr.on('data', (data) => { + console.log(`stderr: ${data}`); +}); + +ls.on('exit', (code) => { + console.log(`child process exited with code ${code}`); +}); +``` + +#### ChildProcess + +##### Event 'exit' + +- `code` `` The exit code if the child process exited on its own. + +The `'exit'` event is emitted when the child process ends. If the process exits, `code` contains the final exit code; otherwise, it is null. + +##### Event 'error' + +- `err` `` The error. + +The `'error'` event is emitted whenever: + +- The process could not be spawned. +- The process could not be killed. + +##### `stdout` + +A `Readable Stream` that represents the child process's `stdout`. + +```javascript +const subprocess = spawn('ls'); + +subprocess.stdout.on('data', (data) => { + console.log(`Received chunk ${data}`); +}); +``` + +#### `stderr` + +A `Readable Stream` that represents the child process's `stderr`. + +### fullScreen + +Request the WebView enter/exit full screen. + +```javascript +import { fullScreen } from 'kernelsu'; +fullScreen(true); +``` + +### toast + +Show a toast message. + +```javascript +import { toast } from 'kernelsu'; +toast('Hello, world!'); +``` + +### moduleInfo + +Get module info. + +```javascript +import { moduleInfo } from 'kernelsu'; +// print moduleId in console +console.log(moduleInfo()); +``` diff --git a/js/index.d.ts b/js/index.d.ts new file mode 100644 index 0000000..c927817 --- /dev/null +++ b/js/index.d.ts @@ -0,0 +1,48 @@ +interface ExecOptions { + cwd?: string, + env?: { [key: string]: string } +} + +interface ExecResults { + errno: number, + stdout: string, + stderr: string +} + +declare function exec(command: string): Promise; +declare function exec(command: string, options: ExecOptions): Promise; + +interface SpawnOptions { + cwd?: string, + env?: { [key: string]: string } +} + +interface Stdio { + on(event: 'data', callback: (data: string) => void) +} + +interface ChildProcess { + stdout: Stdio, + stderr: Stdio, + on(event: 'exit', callback: (code: number) => void) + on(event: 'error', callback: (err: any) => void) +} + +declare function spawn(command: string): ChildProcess; +declare function spawn(command: string, args: string[]): ChildProcess; +declare function spawn(command: string, options: SpawnOptions): ChildProcess; +declare function spawn(command: string, args: string[], options: SpawnOptions): ChildProcess; + +declare function fullScreen(isFullScreen: boolean); + +declare function toast(message: string); + +declare function moduleInfo(): string; + +export { + exec, + spawn, + fullScreen, + toast, + moduleInfo +} diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..29b928a --- /dev/null +++ b/js/index.js @@ -0,0 +1,119 @@ +let callbackCounter = 0; +function getUniqueCallbackName(prefix) { + return `${prefix}_callback_${Date.now()}_${callbackCounter++}`; +} + +export function exec(command, options) { + if (typeof options === "undefined") { + options = {}; + } + + return new Promise((resolve, reject) => { + // Generate a unique callback function name + const callbackFuncName = getUniqueCallbackName("exec"); + + // Define the success callback function + window[callbackFuncName] = (errno, stdout, stderr) => { + resolve({ errno, stdout, stderr }); + cleanup(callbackFuncName); + }; + + function cleanup(successName) { + delete window[successName]; + } + + try { + ksu.exec(command, JSON.stringify(options), callbackFuncName); + } catch (error) { + reject(error); + cleanup(callbackFuncName); + } + }); +} + +function Stdio() { + this.listeners = {}; + } + + Stdio.prototype.on = function (event, listener) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(listener); + }; + + Stdio.prototype.emit = function (event, ...args) { + if (this.listeners[event]) { + this.listeners[event].forEach((listener) => listener(...args)); + } + }; + + function ChildProcess() { + this.listeners = {}; + this.stdin = new Stdio(); + this.stdout = new Stdio(); + this.stderr = new Stdio(); + } + + ChildProcess.prototype.on = function (event, listener) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(listener); + }; + + ChildProcess.prototype.emit = function (event, ...args) { + if (this.listeners[event]) { + this.listeners[event].forEach((listener) => listener(...args)); + } + }; + + export function spawn(command, args, options) { + if (typeof args === "undefined") { + args = []; + } else if (!(args instanceof Array)) { + // allow for (command, options) signature + options = args; + } + + if (typeof options === "undefined") { + options = {}; + } + + const child = new ChildProcess(); + const childCallbackName = getUniqueCallbackName("spawn"); + window[childCallbackName] = child; + + function cleanup(name) { + delete window[name]; + } + + child.on("exit", code => { + cleanup(childCallbackName); + }); + + try { + ksu.spawn( + command, + JSON.stringify(args), + JSON.stringify(options), + childCallbackName + ); + } catch (error) { + child.emit("error", error); + cleanup(childCallbackName); + } + return child; + } + +export function fullScreen(isFullScreen) { + ksu.fullScreen(isFullScreen); +} + +export function toast(message) { + ksu.toast(message); +} + +export function moduleInfo() { + return ksu.moduleInfo(); +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..12002a0 --- /dev/null +++ b/js/package.json @@ -0,0 +1,26 @@ +{ + "name": "kernelsu", + "version": "1.0.7", + "description": "Library for KernelSU's module WebUI", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "test": "npm run test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tiann/KernelSU.git" + }, + "keywords": [ + "su", + "kernelsu", + "module", + "webui" + ], + "author": "weishu", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/tiann/KernelSU/issues" + }, + "homepage": "https://github.com/tiann/KernelSU#readme" +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..51bef76 --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +alias bk := build_ksud +alias bm := build_manager + +build_ksud: + cross build --target aarch64-linux-android --release --manifest-path ./userspace/ksud/Cargo.toml + +build_manager: build_ksud + cp userspace/ksud/target/aarch64-linux-android/release/ksud manager/app/src/main/jniLibs/arm64-v8a/libksud.so + cd manager && ./gradlew aDebug + +clippy: + cargo fmt --manifest-path ./userspace/ksud/Cargo.toml + cross clippy --target x86_64-pc-windows-gnu --release --manifest-path ./userspace/ksud/Cargo.toml + cross clippy --target aarch64-linux-android --release --manifest-path ./userspace/ksud/Cargo.toml diff --git a/kernel/.clang-format b/kernel/.clang-format new file mode 100644 index 0000000..6453cf9 --- /dev/null +++ b/kernel/.clang-format @@ -0,0 +1,548 @@ +# SPDX-License-Identifier: GPL-2.0 +# +# clang-format configuration file. Intended for clang-format >= 4. +# +# For more information, see: +# +# Documentation/process/clang-format.rst +# https://clang.llvm.org/docs/ClangFormat.html +# https://clang.llvm.org/docs/ClangFormatStyleOptions.html +# +--- +AccessModifierOffset: -4 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +#AlignEscapedNewlines: Left # Unknown to clang-format-4.0 +AlignOperands: true +AlignTrailingComments: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: false +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: true + AfterNamespace: true + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + #AfterExternBlock: false # Unknown to clang-format-5.0 + BeforeCatch: false + BeforeElse: false + IndentBraces: false + #SplitEmptyFunction: true # Unknown to clang-format-4.0 + #SplitEmptyRecord: true # Unknown to clang-format-4.0 + #SplitEmptyNamespace: true # Unknown to clang-format-4.0 +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Custom +#BreakBeforeInheritanceComma: false # Unknown to clang-format-4.0 +BreakBeforeTernaryOperators: false +BreakConstructorInitializersBeforeComma: false +#BreakConstructorInitializers: BeforeComma # Unknown to clang-format-4.0 +BreakAfterJavaFieldAnnotations: false +BreakStringLiterals: false +ColumnLimit: 80 +CommentPragmas: '^ IWYU pragma:' +#CompactNamespaces: false # Unknown to clang-format-4.0 +ConstructorInitializerAllOnOneLineOrOnePerLine: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: false +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +#FixNamespaceComments: false # Unknown to clang-format-4.0 + +# Taken from: +# git grep -h '^#define [^[:space:]]*for_each[^[:space:]]*(' include/ \ +# | sed "s,^#define \([^[:space:]]*for_each[^[:space:]]*\)(.*$, - '\1'," \ +# | sort | uniq +ForEachMacros: + - 'apei_estatus_for_each_section' + - 'ata_for_each_dev' + - 'ata_for_each_link' + - '__ata_qc_for_each' + - 'ata_qc_for_each' + - 'ata_qc_for_each_raw' + - 'ata_qc_for_each_with_internal' + - 'ax25_for_each' + - 'ax25_uid_for_each' + - '__bio_for_each_bvec' + - 'bio_for_each_bvec' + - 'bio_for_each_bvec_all' + - 'bio_for_each_integrity_vec' + - '__bio_for_each_segment' + - 'bio_for_each_segment' + - 'bio_for_each_segment_all' + - 'bio_list_for_each' + - 'bip_for_each_vec' + - 'bitmap_for_each_clear_region' + - 'bitmap_for_each_set_region' + - 'blkg_for_each_descendant_post' + - 'blkg_for_each_descendant_pre' + - 'blk_queue_for_each_rl' + - 'bond_for_each_slave' + - 'bond_for_each_slave_rcu' + - 'bpf_for_each_spilled_reg' + - 'btree_for_each_safe128' + - 'btree_for_each_safe32' + - 'btree_for_each_safe64' + - 'btree_for_each_safel' + - 'card_for_each_dev' + - 'cgroup_taskset_for_each' + - 'cgroup_taskset_for_each_leader' + - 'cpufreq_for_each_entry' + - 'cpufreq_for_each_entry_idx' + - 'cpufreq_for_each_valid_entry' + - 'cpufreq_for_each_valid_entry_idx' + - 'css_for_each_child' + - 'css_for_each_descendant_post' + - 'css_for_each_descendant_pre' + - 'device_for_each_child_node' + - 'dma_fence_chain_for_each' + - 'do_for_each_ftrace_op' + - 'drm_atomic_crtc_for_each_plane' + - 'drm_atomic_crtc_state_for_each_plane' + - 'drm_atomic_crtc_state_for_each_plane_state' + - 'drm_atomic_for_each_plane_damage' + - 'drm_client_for_each_connector_iter' + - 'drm_client_for_each_modeset' + - 'drm_connector_for_each_possible_encoder' + - 'drm_for_each_bridge_in_chain' + - 'drm_for_each_connector_iter' + - 'drm_for_each_crtc' + - 'drm_for_each_encoder' + - 'drm_for_each_encoder_mask' + - 'drm_for_each_fb' + - 'drm_for_each_legacy_plane' + - 'drm_for_each_plane' + - 'drm_for_each_plane_mask' + - 'drm_for_each_privobj' + - 'drm_mm_for_each_hole' + - 'drm_mm_for_each_node' + - 'drm_mm_for_each_node_in_range' + - 'drm_mm_for_each_node_safe' + - 'flow_action_for_each' + - 'for_each_active_dev_scope' + - 'for_each_active_drhd_unit' + - 'for_each_active_iommu' + - 'for_each_aggr_pgid' + - 'for_each_available_child_of_node' + - 'for_each_bio' + - 'for_each_board_func_rsrc' + - 'for_each_bvec' + - 'for_each_card_auxs' + - 'for_each_card_auxs_safe' + - 'for_each_card_components' + - 'for_each_card_dapms' + - 'for_each_card_pre_auxs' + - 'for_each_card_prelinks' + - 'for_each_card_rtds' + - 'for_each_card_rtds_safe' + - 'for_each_card_widgets' + - 'for_each_card_widgets_safe' + - 'for_each_cgroup_storage_type' + - 'for_each_child_of_node' + - 'for_each_clear_bit' + - 'for_each_clear_bit_from' + - 'for_each_cmsghdr' + - 'for_each_compatible_node' + - 'for_each_component_dais' + - 'for_each_component_dais_safe' + - 'for_each_comp_order' + - 'for_each_console' + - 'for_each_cpu' + - 'for_each_cpu_and' + - 'for_each_cpu_not' + - 'for_each_cpu_wrap' + - 'for_each_dapm_widgets' + - 'for_each_dev_addr' + - 'for_each_dev_scope' + - 'for_each_displayid_db' + - 'for_each_dma_cap_mask' + - 'for_each_dpcm_be' + - 'for_each_dpcm_be_rollback' + - 'for_each_dpcm_be_safe' + - 'for_each_dpcm_fe' + - 'for_each_drhd_unit' + - 'for_each_dss_dev' + - 'for_each_efi_memory_desc' + - 'for_each_efi_memory_desc_in_map' + - 'for_each_element' + - 'for_each_element_extid' + - 'for_each_element_id' + - 'for_each_endpoint_of_node' + - 'for_each_evictable_lru' + - 'for_each_fib6_node_rt_rcu' + - 'for_each_fib6_walker_rt' + - 'for_each_free_mem_pfn_range_in_zone' + - 'for_each_free_mem_pfn_range_in_zone_from' + - 'for_each_free_mem_range' + - 'for_each_free_mem_range_reverse' + - 'for_each_func_rsrc' + - 'for_each_hstate' + - 'for_each_if' + - 'for_each_iommu' + - 'for_each_ip_tunnel_rcu' + - 'for_each_irq_nr' + - 'for_each_link_codecs' + - 'for_each_link_cpus' + - 'for_each_link_platforms' + - 'for_each_lru' + - 'for_each_matching_node' + - 'for_each_matching_node_and_match' + - 'for_each_member' + - 'for_each_mem_region' + - 'for_each_memblock_type' + - 'for_each_memcg_cache_index' + - 'for_each_mem_pfn_range' + - '__for_each_mem_range' + - 'for_each_mem_range' + - '__for_each_mem_range_rev' + - 'for_each_mem_range_rev' + - 'for_each_migratetype_order' + - 'for_each_msi_entry' + - 'for_each_msi_entry_safe' + - 'for_each_net' + - 'for_each_net_continue_reverse' + - 'for_each_netdev' + - 'for_each_netdev_continue' + - 'for_each_netdev_continue_rcu' + - 'for_each_netdev_continue_reverse' + - 'for_each_netdev_feature' + - 'for_each_netdev_in_bond_rcu' + - 'for_each_netdev_rcu' + - 'for_each_netdev_reverse' + - 'for_each_netdev_safe' + - 'for_each_net_rcu' + - 'for_each_new_connector_in_state' + - 'for_each_new_crtc_in_state' + - 'for_each_new_mst_mgr_in_state' + - 'for_each_new_plane_in_state' + - 'for_each_new_private_obj_in_state' + - 'for_each_node' + - 'for_each_node_by_name' + - 'for_each_node_by_type' + - 'for_each_node_mask' + - 'for_each_node_state' + - 'for_each_node_with_cpus' + - 'for_each_node_with_property' + - 'for_each_nonreserved_multicast_dest_pgid' + - 'for_each_of_allnodes' + - 'for_each_of_allnodes_from' + - 'for_each_of_cpu_node' + - 'for_each_of_pci_range' + - 'for_each_old_connector_in_state' + - 'for_each_old_crtc_in_state' + - 'for_each_old_mst_mgr_in_state' + - 'for_each_oldnew_connector_in_state' + - 'for_each_oldnew_crtc_in_state' + - 'for_each_oldnew_mst_mgr_in_state' + - 'for_each_oldnew_plane_in_state' + - 'for_each_oldnew_plane_in_state_reverse' + - 'for_each_oldnew_private_obj_in_state' + - 'for_each_old_plane_in_state' + - 'for_each_old_private_obj_in_state' + - 'for_each_online_cpu' + - 'for_each_online_node' + - 'for_each_online_pgdat' + - 'for_each_pci_bridge' + - 'for_each_pci_dev' + - 'for_each_pci_msi_entry' + - 'for_each_pcm_streams' + - 'for_each_physmem_range' + - 'for_each_populated_zone' + - 'for_each_possible_cpu' + - 'for_each_present_cpu' + - 'for_each_prime_number' + - 'for_each_prime_number_from' + - 'for_each_process' + - 'for_each_process_thread' + - 'for_each_property_of_node' + - 'for_each_registered_fb' + - 'for_each_requested_gpio' + - 'for_each_requested_gpio_in_range' + - 'for_each_reserved_mem_range' + - 'for_each_reserved_mem_region' + - 'for_each_rtd_codec_dais' + - 'for_each_rtd_codec_dais_rollback' + - 'for_each_rtd_components' + - 'for_each_rtd_cpu_dais' + - 'for_each_rtd_cpu_dais_rollback' + - 'for_each_rtd_dais' + - 'for_each_set_bit' + - 'for_each_set_bit_from' + - 'for_each_set_clump8' + - 'for_each_sg' + - 'for_each_sg_dma_page' + - 'for_each_sg_page' + - 'for_each_sgtable_dma_page' + - 'for_each_sgtable_dma_sg' + - 'for_each_sgtable_page' + - 'for_each_sgtable_sg' + - 'for_each_sibling_event' + - 'for_each_subelement' + - 'for_each_subelement_extid' + - 'for_each_subelement_id' + - '__for_each_thread' + - 'for_each_thread' + - 'for_each_unicast_dest_pgid' + - 'for_each_wakeup_source' + - 'for_each_zone' + - 'for_each_zone_zonelist' + - 'for_each_zone_zonelist_nodemask' + - 'fwnode_for_each_available_child_node' + - 'fwnode_for_each_child_node' + - 'fwnode_graph_for_each_endpoint' + - 'gadget_for_each_ep' + - 'genradix_for_each' + - 'genradix_for_each_from' + - 'hash_for_each' + - 'hash_for_each_possible' + - 'hash_for_each_possible_rcu' + - 'hash_for_each_possible_rcu_notrace' + - 'hash_for_each_possible_safe' + - 'hash_for_each_rcu' + - 'hash_for_each_safe' + - 'hctx_for_each_ctx' + - 'hlist_bl_for_each_entry' + - 'hlist_bl_for_each_entry_rcu' + - 'hlist_bl_for_each_entry_safe' + - 'hlist_for_each' + - 'hlist_for_each_entry' + - 'hlist_for_each_entry_continue' + - 'hlist_for_each_entry_continue_rcu' + - 'hlist_for_each_entry_continue_rcu_bh' + - 'hlist_for_each_entry_from' + - 'hlist_for_each_entry_from_rcu' + - 'hlist_for_each_entry_rcu' + - 'hlist_for_each_entry_rcu_bh' + - 'hlist_for_each_entry_rcu_notrace' + - 'hlist_for_each_entry_safe' + - '__hlist_for_each_rcu' + - 'hlist_for_each_safe' + - 'hlist_nulls_for_each_entry' + - 'hlist_nulls_for_each_entry_from' + - 'hlist_nulls_for_each_entry_rcu' + - 'hlist_nulls_for_each_entry_safe' + - 'i3c_bus_for_each_i2cdev' + - 'i3c_bus_for_each_i3cdev' + - 'ide_host_for_each_port' + - 'ide_port_for_each_dev' + - 'ide_port_for_each_present_dev' + - 'idr_for_each_entry' + - 'idr_for_each_entry_continue' + - 'idr_for_each_entry_continue_ul' + - 'idr_for_each_entry_ul' + - 'in_dev_for_each_ifa_rcu' + - 'in_dev_for_each_ifa_rtnl' + - 'inet_bind_bucket_for_each' + - 'inet_lhash2_for_each_icsk_rcu' + - 'key_for_each' + - 'key_for_each_safe' + - 'klp_for_each_func' + - 'klp_for_each_func_safe' + - 'klp_for_each_func_static' + - 'klp_for_each_object' + - 'klp_for_each_object_safe' + - 'klp_for_each_object_static' + - 'kunit_suite_for_each_test_case' + - 'kvm_for_each_memslot' + - 'kvm_for_each_vcpu' + - 'list_for_each' + - 'list_for_each_codec' + - 'list_for_each_codec_safe' + - 'list_for_each_continue' + - 'list_for_each_entry' + - 'list_for_each_entry_continue' + - 'list_for_each_entry_continue_rcu' + - 'list_for_each_entry_continue_reverse' + - 'list_for_each_entry_from' + - 'list_for_each_entry_from_rcu' + - 'list_for_each_entry_from_reverse' + - 'list_for_each_entry_lockless' + - 'list_for_each_entry_rcu' + - 'list_for_each_entry_reverse' + - 'list_for_each_entry_safe' + - 'list_for_each_entry_safe_continue' + - 'list_for_each_entry_safe_from' + - 'list_for_each_entry_safe_reverse' + - 'list_for_each_prev' + - 'list_for_each_prev_safe' + - 'list_for_each_safe' + - 'llist_for_each' + - 'llist_for_each_entry' + - 'llist_for_each_entry_safe' + - 'llist_for_each_safe' + - 'mci_for_each_dimm' + - 'media_device_for_each_entity' + - 'media_device_for_each_intf' + - 'media_device_for_each_link' + - 'media_device_for_each_pad' + - 'nanddev_io_for_each_page' + - 'netdev_for_each_lower_dev' + - 'netdev_for_each_lower_private' + - 'netdev_for_each_lower_private_rcu' + - 'netdev_for_each_mc_addr' + - 'netdev_for_each_uc_addr' + - 'netdev_for_each_upper_dev_rcu' + - 'netdev_hw_addr_list_for_each' + - 'nft_rule_for_each_expr' + - 'nla_for_each_attr' + - 'nla_for_each_nested' + - 'nlmsg_for_each_attr' + - 'nlmsg_for_each_msg' + - 'nr_neigh_for_each' + - 'nr_neigh_for_each_safe' + - 'nr_node_for_each' + - 'nr_node_for_each_safe' + - 'of_for_each_phandle' + - 'of_property_for_each_string' + - 'of_property_for_each_u32' + - 'pci_bus_for_each_resource' + - 'pcm_for_each_format' + - 'ping_portaddr_for_each_entry' + - 'plist_for_each' + - 'plist_for_each_continue' + - 'plist_for_each_entry' + - 'plist_for_each_entry_continue' + - 'plist_for_each_entry_safe' + - 'plist_for_each_safe' + - 'pnp_for_each_card' + - 'pnp_for_each_dev' + - 'protocol_for_each_card' + - 'protocol_for_each_dev' + - 'queue_for_each_hw_ctx' + - 'radix_tree_for_each_slot' + - 'radix_tree_for_each_tagged' + - 'rbtree_postorder_for_each_entry_safe' + - 'rdma_for_each_block' + - 'rdma_for_each_port' + - 'rdma_umem_for_each_dma_block' + - 'resource_list_for_each_entry' + - 'resource_list_for_each_entry_safe' + - 'rhl_for_each_entry_rcu' + - 'rhl_for_each_rcu' + - 'rht_for_each' + - 'rht_for_each_entry' + - 'rht_for_each_entry_from' + - 'rht_for_each_entry_rcu' + - 'rht_for_each_entry_rcu_from' + - 'rht_for_each_entry_safe' + - 'rht_for_each_from' + - 'rht_for_each_rcu' + - 'rht_for_each_rcu_from' + - '__rq_for_each_bio' + - 'rq_for_each_bvec' + - 'rq_for_each_segment' + - 'scsi_for_each_prot_sg' + - 'scsi_for_each_sg' + - 'sctp_for_each_hentry' + - 'sctp_skb_for_each' + - 'shdma_for_each_chan' + - '__shost_for_each_device' + - 'shost_for_each_device' + - 'sk_for_each' + - 'sk_for_each_bound' + - 'sk_for_each_entry_offset_rcu' + - 'sk_for_each_from' + - 'sk_for_each_rcu' + - 'sk_for_each_safe' + - 'sk_nulls_for_each' + - 'sk_nulls_for_each_from' + - 'sk_nulls_for_each_rcu' + - 'snd_array_for_each' + - 'snd_pcm_group_for_each_entry' + - 'snd_soc_dapm_widget_for_each_path' + - 'snd_soc_dapm_widget_for_each_path_safe' + - 'snd_soc_dapm_widget_for_each_sink_path' + - 'snd_soc_dapm_widget_for_each_source_path' + - 'tb_property_for_each' + - 'tcf_exts_for_each_action' + - 'udp_portaddr_for_each_entry' + - 'udp_portaddr_for_each_entry_rcu' + - 'usb_hub_for_each_child' + - 'v4l2_device_for_each_subdev' + - 'v4l2_m2m_for_each_dst_buf' + - 'v4l2_m2m_for_each_dst_buf_safe' + - 'v4l2_m2m_for_each_src_buf' + - 'v4l2_m2m_for_each_src_buf_safe' + - 'virtio_device_for_each_vq' + - 'while_for_each_ftrace_op' + - 'xa_for_each' + - 'xa_for_each_marked' + - 'xa_for_each_range' + - 'xa_for_each_start' + - 'xas_for_each' + - 'xas_for_each_conflict' + - 'xas_for_each_marked' + - 'xbc_array_for_each_value' + - 'xbc_for_each_key_value' + - 'xbc_node_for_each_array_value' + - 'xbc_node_for_each_child' + - 'xbc_node_for_each_key_value' + - 'zorro_for_each_dev' + +#IncludeBlocks: Preserve # Unknown to clang-format-5.0 +IncludeCategories: + - Regex: '.*' + Priority: 1 +IncludeIsMainRegex: '(Test)?$' +IndentCaseLabels: false +#IndentPPDirectives: None # Unknown to clang-format-5.0 +IndentWidth: 4 +IndentWrappedFunctionNames: false +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +#ObjCBinPackProtocolList: Auto # Unknown to clang-format-5.0 +ObjCBlockIndentWidth: 4 +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true + +# Taken from git's rules +#PenaltyBreakAssignment: 10 # Unknown to clang-format-4.0 +PenaltyBreakBeforeFirstCallParameter: 30 +PenaltyBreakComment: 10 +PenaltyBreakFirstLessLess: 0 +PenaltyBreakString: 10 +PenaltyExcessCharacter: 100 +PenaltyReturnTypeOnItsOwnLine: 60 + +PointerAlignment: Right +ReflowComments: false +SortIncludes: false +#SortUsingDeclarations: false # Unknown to clang-format-4.0 +SpaceAfterCStyleCast: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +#SpaceBeforeCtorInitializerColon: true # Unknown to clang-format-5.0 +#SpaceBeforeInheritanceColon: true # Unknown to clang-format-5.0 +SpaceBeforeParens: ControlStatements +#SpaceBeforeRangeBasedForLoopColon: true # Unknown to clang-format-5.0 +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: false +SpacesInContainerLiterals: false +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Cpp03 +TabWidth: 4 +UseTab: Never +... diff --git a/kernel/.clangd b/kernel/.clangd new file mode 100644 index 0000000..5efbb7e --- /dev/null +++ b/kernel/.clangd @@ -0,0 +1,4 @@ +Diagnostics: + UnusedIncludes: Strict + ClangTidy: + Remove: bugprone-sizeof-expression diff --git a/kernel/.gitignore b/kernel/.gitignore new file mode 100644 index 0000000..20d68ae --- /dev/null +++ b/kernel/.gitignore @@ -0,0 +1,22 @@ +.cache/ +.thinlto-cache/ +compile_commands.json +*.ko +*.o +*.mod +*.lds +*.mod.o +.*.o* +.*.mod* +*.ko* +*.mod.c +*.symvers* +*.order +.*.ko.cmd +.tmp_versions/ +libs/ +obj/ + +CLAUDE.md +.ddk-version +.vscode/settings.json \ No newline at end of file diff --git a/kernel/.vscode/c_cpp_properties.json b/kernel/.vscode/c_cpp_properties.json new file mode 100644 index 0000000..f661370 --- /dev/null +++ b/kernel/.vscode/c_cpp_properties.json @@ -0,0 +1,11 @@ +{ + "configurations": [ + { + "name": "Linux", + "cStandard": "c11", + "intelliSenseMode": "gcc-arm64", + "compileCommands": "${workspaceFolder}/compile_commands.json" + } + ], + "version": 4 +} \ No newline at end of file diff --git a/kernel/.vscode/generate_compdb.py b/kernel/.vscode/generate_compdb.py new file mode 100644 index 0000000..8866913 --- /dev/null +++ b/kernel/.vscode/generate_compdb.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +from __future__ import print_function, division + +import argparse +import fnmatch +import functools +import json +import math +import multiprocessing +import os +import re +import sys + + +CMD_VAR_RE = re.compile(r'^\s*(?:saved)?cmd_(\S+)\s*:=\s*(.+)\s*$', re.MULTILINE) +SOURCE_VAR_RE = re.compile(r'^\s*source_(\S+)\s*:=\s*(.+)\s*$', re.MULTILINE) + + +def print_progress_bar(progress): + progress_bar = '[' + '|' * int(50 * progress) + '-' * int(50 * (1.0 - progress)) + ']' + print('\r', progress_bar, "{0:.1%}".format(progress), end='\r', file=sys.stderr) + + +def parse_cmd_file(out_dir, cmdfile_path): + with open(cmdfile_path, 'r') as cmdfile: + cmdfile_content = cmdfile.read() + + commands = { match.group(1): match.group(2) for match in CMD_VAR_RE.finditer(cmdfile_content) } + sources = { match.group(1): match.group(2) for match in SOURCE_VAR_RE.finditer(cmdfile_content) } + + return [{ + 'directory': out_dir, + 'command': commands[o_file_name], + 'file': source, + 'output': o_file_name + } for o_file_name, source in sources.items()] + + +def gen_compile_commands(cmd_file_search_path, out_dir): + print("Building *.o.cmd file list...", file=sys.stderr) + + out_dir = os.path.abspath(out_dir) + + if not cmd_file_search_path: + cmd_file_search_path = [out_dir] + + cmd_files = [] + for search_path in cmd_file_search_path: + if (os.path.isdir(search_path)): + for cur_dir, subdir, files in os.walk(search_path): + cmd_files.extend(os.path.join(cur_dir, cmdfile_name) for cmdfile_name in fnmatch.filter(files, '*.o.cmd')) + else: + cmd_files.extend(search_path) + + if not cmd_files: + print("No *.o.cmd files found in", ", ".join(cmd_file_search_path), file=sys.stderr) + return + + print("Parsing *.o.cmd files...", file=sys.stderr) + + n_processed = 0 + print_progress_bar(0) + + compdb = [] + pool = multiprocessing.Pool() + try: + for compdb_chunk in pool.imap_unordered(functools.partial(parse_cmd_file, out_dir), cmd_files, chunksize=int(math.sqrt(len(cmd_files)))): + compdb.extend(compdb_chunk) + n_processed += 1 + print_progress_bar(n_processed / len(cmd_files)) + + finally: + pool.terminate() + pool.join() + + print(file=sys.stderr) + print("Writing compile_commands.json...", file=sys.stderr) + + with open('compile_commands.json', 'w') as compdb_file: + json.dump(compdb, compdb_file, indent=1) + + +def main(): + cmd_parser = argparse.ArgumentParser() + cmd_parser.add_argument('-O', '--out-dir', type=str, default=os.getcwd(), help="Build output directory") + cmd_parser.add_argument('cmd_file_search_path', nargs='*', help="*.cmd file search path") + gen_compile_commands(**vars(cmd_parser.parse_args())) + + +if __name__ == '__main__': + main() diff --git a/kernel/.vscode/tasks.json b/kernel/.vscode/tasks.json new file mode 100644 index 0000000..4ed9adb --- /dev/null +++ b/kernel/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Generate compile_commands.json", + "type": "process", + "command": "python", + "args": [ + "${workspaceRoot}/.vscode/generate_compdb.py" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/kernel/Kconfig b/kernel/Kconfig new file mode 100644 index 0000000..fd5e0c1 --- /dev/null +++ b/kernel/Kconfig @@ -0,0 +1,42 @@ +menu "KernelSU" + +config KSU + tristate "KernelSU function support" + default y + help + Enable kernel-level root privileges on Android System. + To compile as a module, choose M here: the + module will be called kernelsu. + +config KSU_DEBUG + bool "KernelSU debug mode" + depends on KSU + default n + help + Enable KernelSU debug mode. + +config KSU_MANUAL_SU + bool "Use manual su" + depends on KSU + default y + help + Use manual su and authorize the corresponding command line and application via prctl + +config KPM + bool "Enable SukiSU KPM" + depends on KSU && 64BIT + default n + help + Enabling this option will activate the KPM feature of SukiSU. + This option is suitable for scenarios where you need to force KPM to be enabled. + but it may affect system stability. + select KALLSYMS + select KALLSYMS_ALL + +config KSU_MANUAL_HOOK + bool "Hook KernelSU manually" + depends on KSU != m + help + If enabled, Hook required KernelSU syscalls with manually-patched function. + +endmenu diff --git a/kernel/LICENSE b/kernel/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/kernel/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) 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 +this service 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 make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. 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. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE 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. + + 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 +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 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, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision 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, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This 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. diff --git a/kernel/Makefile b/kernel/Makefile new file mode 100644 index 0000000..8b246bd --- /dev/null +++ b/kernel/Makefile @@ -0,0 +1,172 @@ +kernelsu-objs := ksu.o +kernelsu-objs += allowlist.o +kernelsu-objs += app_profile.o +kernelsu-objs += dynamic_manager.o +kernelsu-objs += apk_sign.o +kernelsu-objs += sucompat.o +kernelsu-objs += syscall_hook_manager.o +kernelsu-objs += throne_tracker.o +kernelsu-objs += pkg_observer.o +kernelsu-objs += throne_tracker.o +kernelsu-objs += umount_manager.o +kernelsu-objs += setuid_hook.o +kernelsu-objs += kernel_umount.o +kernelsu-objs += supercalls.o +kernelsu-objs += feature.o +kernelsu-objs += ksud.o +kernelsu-objs += embed_ksud.o +kernelsu-objs += seccomp_cache.o +kernelsu-objs += file_wrapper.o +kernelsu-objs += throne_comm.o +kernelsu-objs += sulog.o + +ifeq ($(CONFIG_KSU_MANUAL_SU), y) +ccflags-y += -DCONFIG_KSU_MANUAL_SU +kernelsu-objs += manual_su.o +endif + +kernelsu-objs += selinux/selinux.o +kernelsu-objs += selinux/sepolicy.o +kernelsu-objs += selinux/rules.o +ccflags-y += -I$(srctree)/security/selinux -I$(srctree)/security/selinux/include +ccflags-y += -I$(objtree)/security/selinux -include $(srctree)/include/uapi/asm-generic/errno.h + +obj-$(CONFIG_KSU) += kernelsu.o + +obj-$(CONFIG_KPM) += kpm/ + +REPO_OWNER := SukiSU-Ultra +REPO_NAME := SukiSU-Ultra +REPO_BRANCH := main +KSU_VERSION_API := 4.0.0 + +GIT_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin git +CURL_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin curl + +KDIR := $(KDIR) +MDIR := $(realpath $(dir $(abspath $(lastword $(MAKEFILE_LIST))))) + +ifneq ($(KDIR),) +$(info -- KDIR: $(KDIR)) +$(info -- MDIR: $(MDIR)) +endif + +KSU_GITHUB_VERSION := $(shell $(CURL_BIN) -s "https://api.github.com/repos/$(REPO_OWNER)/$(REPO_NAME)/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') +KSU_GITHUB_VERSION_COMMIT := $(shell $(CURL_BIN) -sI "https://api.github.com/repos/$(REPO_OWNER)/$(REPO_NAME)/commits?sha=$(REPO_BRANCH)&per_page=1" | grep -i "link:" | sed -n 's/.*page=\([0-9]*\)>; rel="last".*/\1/p') + +ifeq ($(findstring $(srctree),$(src)),$(srctree)) + KSU_SRC := $(src) +else + KSU_SRC := $(srctree)/$(src) +endif + +ifneq ($(shell test -e $(KSU_SRC)/../.git && echo "in-tree"),in-tree) + KSU_SRC := $(MDIR) +endif + +LOCAL_GIT_EXISTS := $(shell test -e $(KSU_SRC)/../.git && echo 1 || echo 0) + +define get_ksu_version_full +v$1-$(shell cd $(KSU_SRC); $(GIT_BIN) rev-parse --short=8 HEAD)@$(shell cd $(KSU_SRC); $(GIT_BIN) rev-parse --abbrev-ref HEAD) +endef + +ifeq ($(KSU_GITHUB_VERSION_COMMIT),) + ifeq ($(LOCAL_GIT_EXISTS),1) + $(shell cd $(KSU_SRC); [ -f ../.git/shallow ] && $(GIT_BIN) fetch --unshallow) + KSU_LOCAL_VERSION := $(shell cd $(KSU_SRC); $(GIT_BIN) rev-list --count $(REPO_BRANCH)) + KSU_VERSION := $(shell expr 40000 + $(KSU_LOCAL_VERSION) - 2815) + $(info -- $(REPO_NAME) version (local .git): $(KSU_VERSION)) + else + KSU_VERSION := 13000 + $(warning -- Could not fetch version online or via local .git! Using fallback version: $(KSU_VERSION)) + endif +else + KSU_VERSION := $(shell expr 40000 + $(KSU_GITHUB_VERSION_COMMIT) - 2815) + $(info -- $(REPO_NAME) version (GitHub): $(KSU_VERSION)) +endif + +ifeq ($(KSU_GITHUB_VERSION),) + ifeq ($(LOCAL_GIT_EXISTS),1) + $(shell cd $(KSU_SRC); [ -f ../.git/shallow ] && $(GIT_BIN) fetch --unshallow) + KSU_VERSION_FULL := $(call get_ksu_version_full,$(KSU_VERSION_API)) + $(info -- $(REPO_NAME) version (local .git): $(KSU_VERSION_FULL)) + $(info -- $(REPO_NAME) Formatted version (local .git): $(KSU_VERSION)) + else + KSU_VERSION_FULL := v$(KSU_VERSION_API)-$(REPO_NAME)-unknown@unknown + $(warning -- $(REPO_NAME) version: $(KSU_VERSION_FULL)) + endif +else + $(shell cd $(KSU_SRC); [ -f ../.git/shallow ] && $(GIT_BIN) fetch --unshallow) + KSU_VERSION_FULL := $(call get_ksu_version_full,$(KSU_GITHUB_VERSION)) + $(info -- $(REPO_NAME) version (Github): $(KSU_VERSION_FULL)) +endif + +ccflags-y += -DKSU_VERSION=$(KSU_VERSION) +ccflags-y += -DKSU_VERSION_FULL=\"$(KSU_VERSION_FULL)\" + +# Custom Signs +ifdef KSU_EXPECTED_SIZE +ccflags-y += -DEXPECTED_SIZE=$(KSU_EXPECTED_SIZE) +$(info -- Custom KernelSU Manager signature size: $(KSU_EXPECTED_SIZE)) +endif + +ifdef KSU_EXPECTED_HASH +ccflags-y += -DEXPECTED_HASH=\"$(KSU_EXPECTED_HASH)\" +$(info -- Custom KernelSU Manager signature hash: $(KSU_EXPECTED_HASH)) +endif + +ifdef KSU_MANAGER_PACKAGE +ccflags-y += -DKSU_MANAGER_PACKAGE=\"$(KSU_MANAGER_PACKAGE)\" +$(info -- SukiSU Manager package name: $(KSU_MANAGER_PACKAGE)) +endif + +ifeq ($(CONFIG_KSU_MANUAL_HOOK), y) +ccflags-y += -DKSU_MANUAL_HOOK +$(info -- SukiSU: KSU_MANUAL_HOOK Temporarily discontinued)) +else +ccflags-y += -DKSU_KPROBES_HOOK +ccflags-y += -DKSU_TP_HOOK +$(info -- SukiSU: KSU_TRACEPOINT_HOOK) +endif + +KERNEL_VERSION := $(VERSION).$(PATCHLEVEL) +KERNEL_TYPE := Non-GKI +# Check for GKI 2.0 (5.10+ or 6.x+) +ifneq ($(shell test \( $(VERSION) -ge 5 -a $(PATCHLEVEL) -ge 10 \) -o $(VERSION) -ge 6; echo $$?),0) +# Check for GKI 1.0 (5.4) +ifeq ($(shell test $(VERSION)-$(PATCHLEVEL) = 5-4; echo $$?),0) +KERNEL_TYPE := GKI 1.0 +endif +else +KERNEL_TYPE := GKI 2.0 +endif +$(info -- KERNEL_VERSION: $(KERNEL_VERSION)) +$(info -- KERNEL_TYPE: $(KERNEL_TYPE)) + +ifeq ($(CONFIG_KPM), y) +$(info -- KPM is enabled) +else +$(info -- KPM is disabled) +endif + +# Check new vfs_getattr() +ifeq ($(shell grep -A1 "^int vfs_getattr" $(srctree)/fs/stat.c | grep -q "query_flags" ; echo $$?),0) +ccflags-y += -DKSU_HAS_NEW_VFS_GETATTR +endif + +# Function proc_ops check +ifeq ($(shell grep -q "struct proc_ops " $(srctree)/include/linux/proc_fs.h; echo $$?),0) +ccflags-y += -DKSU_COMPAT_HAS_PROC_OPS +endif + +ccflags-y += -Wno-strict-prototypes -Wno-int-conversion -Wno-gcc-compat -Wno-missing-prototypes +ccflags-y += -Wno-declaration-after-statement -Wno-unused-function -Wno-unused-variable + +all: + make -C $(KDIR) M=$(MDIR) modules +compdb: + python3 $(MDIR)/.vscode/generate_compdb.py -O $(KDIR) $(MDIR) +clean: + make -C $(KDIR) M=$(MDIR) clean + +# Keep a new line here!! Because someone may append config diff --git a/kernel/allowlist.c b/kernel/allowlist.c new file mode 100644 index 0000000..c3f7a5b --- /dev/null +++ b/kernel/allowlist.c @@ -0,0 +1,631 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 14, 0) +#include +#endif + +#include "klog.h" // IWYU pragma: keep +#include "ksud.h" +#include "selinux/selinux.h" +#include "allowlist.h" +#include "manager.h" +#include "syscall_hook_manager.h" + +#define FILE_MAGIC 0x7f4b5355 // ' KSU', u32 +#define FILE_FORMAT_VERSION 3 // u32 + +#define KSU_APP_PROFILE_PRESERVE_UID 9999 // NOBODY_UID +#define KSU_DEFAULT_SELINUX_DOMAIN "u:r:su:s0" + +static DEFINE_MUTEX(allowlist_mutex); + +// default profiles, these may be used frequently, so we cache it +static struct root_profile default_root_profile; +static struct non_root_profile default_non_root_profile; + +void persistent_allow_list(void); + +static int allow_list_arr[PAGE_SIZE / sizeof(int)] __read_mostly + __aligned(PAGE_SIZE); +static int allow_list_pointer __read_mostly = 0; + +static void remove_uid_from_arr(uid_t uid) +{ + int *temp_arr; + int i, j; + + if (allow_list_pointer == 0) + return; + + temp_arr = kzalloc(sizeof(allow_list_arr), GFP_KERNEL); + if (temp_arr == NULL) { + pr_err("%s: unable to allocate memory\n", __func__); + return; + } + + for (i = j = 0; i < allow_list_pointer; i++) { + if (allow_list_arr[i] == uid) + continue; + temp_arr[j++] = allow_list_arr[i]; + } + + allow_list_pointer = j; + + for (; j < ARRAY_SIZE(allow_list_arr); j++) + temp_arr[j] = -1; + + memcpy(&allow_list_arr, temp_arr, PAGE_SIZE); + kfree(temp_arr); +} + +static void init_default_profiles(void) +{ + kernel_cap_t full_cap = CAP_FULL_SET; + + default_root_profile.uid = 0; + default_root_profile.gid = 0; + default_root_profile.groups_count = 1; + default_root_profile.groups[0] = 0; + memcpy(&default_root_profile.capabilities.effective, &full_cap, + sizeof(default_root_profile.capabilities.effective)); + default_root_profile.namespaces = 0; + strcpy(default_root_profile.selinux_domain, KSU_DEFAULT_SELINUX_DOMAIN); + + // This means that we will umount modules by default! + default_non_root_profile.umount_modules = true; +} + +struct perm_data { + struct list_head list; + struct app_profile profile; +}; + +static struct list_head allow_list; + +static uint8_t allow_list_bitmap[PAGE_SIZE] __read_mostly __aligned(PAGE_SIZE); +#define BITMAP_UID_MAX ((sizeof(allow_list_bitmap) * BITS_PER_BYTE) - 1) + +#define KERNEL_SU_ALLOWLIST "/data/adb/ksu/.allowlist" + +void ksu_show_allow_list(void) +{ + struct perm_data *p = NULL; + struct list_head *pos = NULL; + pr_info("ksu_show_allow_list\n"); + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + pr_info("uid :%d, allow: %d\n", p->profile.current_uid, + p->profile.allow_su); + } +} + +#ifdef CONFIG_KSU_DEBUG +static void ksu_grant_root_to_shell(void) +{ + struct app_profile profile = { + .version = KSU_APP_PROFILE_VER, + .allow_su = true, + .current_uid = 2000, + }; + strcpy(profile.key, "com.android.shell"); + strcpy(profile.rp_config.profile.selinux_domain, + KSU_DEFAULT_SELINUX_DOMAIN); + ksu_set_app_profile(&profile, false); +} +#endif + +bool ksu_get_app_profile(struct app_profile *profile) +{ + struct perm_data *p = NULL; + struct list_head *pos = NULL; + bool found = false; + + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + bool uid_match = profile->current_uid == p->profile.current_uid; + if (uid_match) { + // found it, override it with ours + memcpy(profile, &p->profile, sizeof(*profile)); + found = true; + goto exit; + } + } + +exit: + return found; +} + +static inline bool forbid_system_uid(uid_t uid) +{ +#define SHELL_UID 2000 +#define SYSTEM_UID 1000 + return uid < SHELL_UID && uid != SYSTEM_UID; +} + +static bool profile_valid(struct app_profile *profile) +{ + if (!profile) { + return false; + } + + if (profile->version < KSU_APP_PROFILE_VER) { + pr_info("Unsupported profile version: %d\n", profile->version); + return false; + } + + if (profile->allow_su) { + if (profile->rp_config.profile.groups_count > KSU_MAX_GROUPS) { + return false; + } + + if (strlen(profile->rp_config.profile.selinux_domain) == 0) { + return false; + } + } + + return true; +} + +bool ksu_set_app_profile(struct app_profile *profile, bool persist) +{ + struct perm_data *p = NULL; + struct list_head *pos = NULL; + bool result = false; + + if (!profile_valid(profile)) { + pr_err("Failed to set app profile: invalid profile!\n"); + return false; + } + + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + // both uid and package must match, otherwise it will break multiple package with different user id + if (profile->current_uid == p->profile.current_uid && + !strcmp(profile->key, p->profile.key)) { + // found it, just override it all! + memcpy(&p->profile, profile, sizeof(*profile)); + result = true; + goto out; + } + } + + // not found, alloc a new node! + p = (struct perm_data *)kzalloc(sizeof(struct perm_data), GFP_KERNEL); + if (!p) { + pr_err("ksu_set_app_profile alloc failed\n"); + return false; + } + + memcpy(&p->profile, profile, sizeof(*profile)); + if (profile->allow_su) { + pr_info("set root profile, key: %s, uid: %d, gid: %d, context: %s\n", + profile->key, profile->current_uid, + profile->rp_config.profile.gid, + profile->rp_config.profile.selinux_domain); + } else { + pr_info("set app profile, key: %s, uid: %d, umount modules: %d\n", + profile->key, profile->current_uid, + profile->nrp_config.profile.umount_modules); + } + list_add_tail(&p->list, &allow_list); + +out: + if (profile->current_uid <= BITMAP_UID_MAX) { + if (profile->allow_su) + allow_list_bitmap[profile->current_uid / BITS_PER_BYTE] |= + 1 << (profile->current_uid % BITS_PER_BYTE); + else + allow_list_bitmap[profile->current_uid / BITS_PER_BYTE] &= + ~(1 << (profile->current_uid % BITS_PER_BYTE)); + } else { + if (profile->allow_su) { + /* + * 1024 apps with uid higher than BITMAP_UID_MAX + * registered to request superuser? + */ + if (allow_list_pointer >= ARRAY_SIZE(allow_list_arr)) { + pr_err("too many apps registered\n"); + WARN_ON(1); + return false; + } + allow_list_arr[allow_list_pointer++] = profile->current_uid; + } else { + remove_uid_from_arr(profile->current_uid); + } + } + result = true; + + // check if the default profiles is changed, cache it to a single struct to accelerate access. + if (unlikely(!strcmp(profile->key, "$"))) { + // set default non root profile + memcpy(&default_non_root_profile, &profile->nrp_config.profile, + sizeof(default_non_root_profile)); + } + + if (unlikely(!strcmp(profile->key, "#"))) { + // set default root profile + memcpy(&default_root_profile, &profile->rp_config.profile, + sizeof(default_root_profile)); + } + + if (persist) { + persistent_allow_list(); + // FIXME: use a new flag + ksu_mark_running_process(); + } + + return result; +} + +bool __ksu_is_allow_uid(uid_t uid) +{ + int i; + + if (forbid_system_uid(uid)) { + // do not bother going through the list if it's system + return false; + } + + if (likely(ksu_is_manager_uid_valid()) && + unlikely(ksu_get_manager_uid() == uid)) { + // manager is always allowed! + return true; + } + + if (likely(uid <= BITMAP_UID_MAX)) { + return !!(allow_list_bitmap[uid / BITS_PER_BYTE] & + (1 << (uid % BITS_PER_BYTE))); + } else { + for (i = 0; i < allow_list_pointer; i++) { + if (allow_list_arr[i] == uid) + return true; + } + } + + return false; +} + +bool __ksu_is_allow_uid_for_current(uid_t uid) +{ + if (unlikely(uid == 0)) { + // already root, but only allow our domain. + return is_ksu_domain(); + } + return __ksu_is_allow_uid(uid); +} + +bool ksu_uid_should_umount(uid_t uid) +{ + struct app_profile profile = { .current_uid = uid }; + if (likely(ksu_is_manager_uid_valid()) && + unlikely(ksu_get_manager_uid() == uid)) { + // we should not umount on manager! + return false; + } + bool found = ksu_get_app_profile(&profile); + if (!found) { + // no app profile found, it must be non root app + return default_non_root_profile.umount_modules; + } + if (profile.allow_su) { + // if found and it is granted to su, we shouldn't umount for it + return false; + } else { + // found an app profile + if (profile.nrp_config.use_default) { + return default_non_root_profile.umount_modules; + } else { + return profile.nrp_config.profile.umount_modules; + } + } +} + +struct root_profile *ksu_get_root_profile(uid_t uid) +{ + struct perm_data *p = NULL; + struct list_head *pos = NULL; + + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + if (uid == p->profile.current_uid && p->profile.allow_su) { + if (!p->profile.rp_config.use_default) { + return &p->profile.rp_config.profile; + } + } + } + + // use default profile + return &default_root_profile; +} + +bool ksu_get_allow_list(int *array, int *length, bool allow) +{ + struct perm_data *p = NULL; + struct list_head *pos = NULL; + int i = 0; + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + // pr_info("get_allow_list uid: %d allow: %d\n", p->uid, p->allow); + if (p->profile.allow_su == allow) { + array[i++] = p->profile.current_uid; + } + } + *length = i; + + return true; +} + +static void do_persistent_allow_list(struct callback_head *_cb) +{ + u32 magic = FILE_MAGIC; + u32 version = FILE_FORMAT_VERSION; + struct perm_data *p = NULL; + struct list_head *pos = NULL; + loff_t off = 0; + + mutex_lock(&allowlist_mutex); + struct file *fp = + filp_open(KERNEL_SU_ALLOWLIST, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (IS_ERR(fp)) { + pr_err("save_allow_list create file failed: %ld\n", PTR_ERR(fp)); + goto unlock; + } + + // store magic and version + if (kernel_write(fp, &magic, sizeof(magic), &off) != sizeof(magic)) { + pr_err("save_allow_list write magic failed.\n"); + goto close_file; + } + + if (kernel_write(fp, &version, sizeof(version), &off) != sizeof(version)) { + pr_err("save_allow_list write version failed.\n"); + goto close_file; + } + + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + pr_info("save allow list, name: %s uid :%d, allow: %d\n", + p->profile.key, p->profile.current_uid, p->profile.allow_su); + + kernel_write(fp, &p->profile, sizeof(p->profile), &off); + } + +close_file: + filp_close(fp, 0); +unlock: + mutex_unlock(&allowlist_mutex); + kfree(_cb); +} + +void persistent_allow_list() +{ + struct task_struct *tsk; + + tsk = get_pid_task(find_vpid(1), PIDTYPE_PID); + if (!tsk) { + pr_err("save_allow_list find init task err\n"); + return; + } + + struct callback_head *cb = + kzalloc(sizeof(struct callback_head), GFP_KERNEL); + if (!cb) { + pr_err("save_allow_list alloc cb err\b"); + goto put_task; + } + cb->func = do_persistent_allow_list; + task_work_add(tsk, cb, TWA_RESUME); + +put_task: + put_task_struct(tsk); +} + +void ksu_load_allow_list() +{ + loff_t off = 0; + ssize_t ret = 0; + struct file *fp = NULL; + u32 magic; + u32 version; + +#ifdef CONFIG_KSU_DEBUG + // always allow adb shell by default + ksu_grant_root_to_shell(); +#endif + + // load allowlist now! + fp = filp_open(KERNEL_SU_ALLOWLIST, O_RDONLY, 0); + if (IS_ERR(fp)) { + pr_err("load_allow_list open file failed: %ld\n", PTR_ERR(fp)); + return; + } + + // verify magic + if (kernel_read(fp, &magic, sizeof(magic), &off) != sizeof(magic) || + magic != FILE_MAGIC) { + pr_err("allowlist file invalid: %d!\n", magic); + goto exit; + } + + if (kernel_read(fp, &version, sizeof(version), &off) != sizeof(version)) { + pr_err("allowlist read version: %d failed\n", version); + goto exit; + } + + pr_info("allowlist version: %d\n", version); + + while (true) { + struct app_profile profile; + + ret = kernel_read(fp, &profile, sizeof(profile), &off); + + if (ret <= 0) { + pr_info("load_allow_list read err: %zd\n", ret); + break; + } + + pr_info("load_allow_uid, name: %s, uid: %d, allow: %d\n", profile.key, + profile.current_uid, profile.allow_su); + ksu_set_app_profile(&profile, false); + } + +exit: + ksu_show_allow_list(); + filp_close(fp, 0); +} + +void ksu_prune_allowlist(bool (*is_uid_valid)(uid_t, char *, void *), + void *data) +{ + struct perm_data *np = NULL; + struct perm_data *n = NULL; + + if (!ksu_boot_completed) { + pr_info("boot not completed, skip prune\n"); + return; + } + + bool modified = false; + // TODO: use RCU! + mutex_lock(&allowlist_mutex); + list_for_each_entry_safe (np, n, &allow_list, list) { + uid_t uid = np->profile.current_uid; + char *package = np->profile.key; + // we use this uid for special cases, don't prune it! + bool is_preserved_uid = uid == KSU_APP_PROFILE_PRESERVE_UID; + if (!is_preserved_uid && !is_uid_valid(uid, package, data)) { + modified = true; + pr_info("prune uid: %d, package: %s\n", uid, package); + list_del(&np->list); + if (likely(uid <= BITMAP_UID_MAX)) { + allow_list_bitmap[uid / BITS_PER_BYTE] &= + ~(1 << (uid % BITS_PER_BYTE)); + } + remove_uid_from_arr(uid); + smp_mb(); + kfree(np); + } + } + mutex_unlock(&allowlist_mutex); + + if (modified) { + persistent_allow_list(); + } +} + +void ksu_allowlist_init(void) +{ + int i; + + BUILD_BUG_ON(sizeof(allow_list_bitmap) != PAGE_SIZE); + BUILD_BUG_ON(sizeof(allow_list_arr) != PAGE_SIZE); + + for (i = 0; i < ARRAY_SIZE(allow_list_arr); i++) + allow_list_arr[i] = -1; + + INIT_LIST_HEAD(&allow_list); + + init_default_profiles(); +} + +void ksu_allowlist_exit(void) +{ + struct perm_data *np = NULL; + struct perm_data *n = NULL; + + // free allowlist + mutex_lock(&allowlist_mutex); + list_for_each_entry_safe (np, n, &allow_list, list) { + list_del(&np->list); + kfree(np); + } + mutex_unlock(&allowlist_mutex); +} + +#ifdef CONFIG_KSU_MANUAL_SU +bool ksu_temp_grant_root_once(uid_t uid) +{ + struct app_profile profile = { + .version = KSU_APP_PROFILE_VER, + .allow_su = true, + .current_uid = uid, + }; + + const char *default_key = "com.temp.once"; + + struct perm_data *p = NULL; + struct list_head *pos = NULL; + bool found = false; + + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + if (p->profile.current_uid == uid) { + strcpy(profile.key, p->profile.key); + found = true; + break; + } + } + + if (!found) { + strcpy(profile.key, default_key); + } + + profile.rp_config.profile.uid = default_root_profile.uid; + profile.rp_config.profile.gid = default_root_profile.gid; + profile.rp_config.profile.groups_count = default_root_profile.groups_count; + memcpy(profile.rp_config.profile.groups, default_root_profile.groups, sizeof(default_root_profile.groups)); + memcpy(&profile.rp_config.profile.capabilities, &default_root_profile.capabilities, sizeof(default_root_profile.capabilities)); + profile.rp_config.profile.namespaces = default_root_profile.namespaces; + strcpy(profile.rp_config.profile.selinux_domain, default_root_profile.selinux_domain); + + bool ok = ksu_set_app_profile(&profile, false); + if (ok) + pr_info("pending_root: UID=%d granted and persisted\n", uid); + return ok; +} + +void ksu_temp_revoke_root_once(uid_t uid) +{ + struct app_profile profile = { + .version = KSU_APP_PROFILE_VER, + .allow_su = false, + .current_uid = uid, + }; + + const char *default_key = "com.temp.once"; + + struct perm_data *p = NULL; + struct list_head *pos = NULL; + bool found = false; + + list_for_each (pos, &allow_list) { + p = list_entry(pos, struct perm_data, list); + if (p->profile.current_uid == uid) { + strcpy(profile.key, p->profile.key); + found = true; + break; + } + } + + if (!found) { + strcpy(profile.key, default_key); + } + + profile.nrp_config.profile.umount_modules = default_non_root_profile.umount_modules; + strcpy(profile.rp_config.profile.selinux_domain, KSU_DEFAULT_SELINUX_DOMAIN); + + ksu_set_app_profile(&profile, false); + persistent_allow_list(); + pr_info("pending_root: UID=%d removed and persist updated\n", uid); +} +#endif \ No newline at end of file diff --git a/kernel/allowlist.h b/kernel/allowlist.h new file mode 100644 index 0000000..4bac8c3 --- /dev/null +++ b/kernel/allowlist.h @@ -0,0 +1,49 @@ +#ifndef __KSU_H_ALLOWLIST +#define __KSU_H_ALLOWLIST + +#include +#include +#include "app_profile.h" + +#define PER_USER_RANGE 100000 +#define FIRST_APPLICATION_UID 10000 +#define LAST_APPLICATION_UID 19999 + +void ksu_allowlist_init(void); + +void ksu_allowlist_exit(void); + +void ksu_load_allow_list(void); + +void ksu_show_allow_list(void); + +// Check if the uid is in allow list +bool __ksu_is_allow_uid(uid_t uid); +#define ksu_is_allow_uid(uid) unlikely(__ksu_is_allow_uid(uid)) + +// Check if the uid is in allow list, or current is ksu domain root +bool __ksu_is_allow_uid_for_current(uid_t uid); +#define ksu_is_allow_uid_for_current(uid) unlikely(__ksu_is_allow_uid_for_current(uid)) + +bool ksu_get_allow_list(int *array, int *length, bool allow); + +void ksu_prune_allowlist(bool (*is_uid_exist)(uid_t, char *, void *), void *data); + +bool ksu_get_app_profile(struct app_profile *); +bool ksu_set_app_profile(struct app_profile *, bool persist); + +bool ksu_uid_should_umount(uid_t uid); +struct root_profile *ksu_get_root_profile(uid_t uid); + +static inline bool is_appuid(uid_t uid) +{ + uid_t appid = uid % PER_USER_RANGE; + return appid >= FIRST_APPLICATION_UID && appid <= LAST_APPLICATION_UID; +} + +#ifdef CONFIG_KSU_MANUAL_SU +bool ksu_temp_grant_root_once(uid_t uid); +void ksu_temp_revoke_root_once(uid_t uid); +#endif + +#endif diff --git a/kernel/apk_sign.c b/kernel/apk_sign.c new file mode 100644 index 0000000..271e802 --- /dev/null +++ b/kernel/apk_sign.c @@ -0,0 +1,421 @@ +#include +#include +#include +#include +#include +#include +#ifdef CONFIG_KSU_DEBUG +#include +#endif +#include +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) +#include +#else +#include +#endif + +#include "apk_sign.h" +#include "dynamic_manager.h" +#include "klog.h" // IWYU pragma: keep +#include "manager_sign.h" + +struct sdesc { + struct shash_desc shash; + char ctx[]; +}; + +static apk_sign_key_t apk_sign_keys[] = { + {EXPECTED_SIZE_SHIRKNEKO, EXPECTED_HASH_SHIRKNEKO}, // ShirkNeko/SukiSU +#ifdef EXPECTED_SIZE + {EXPECTED_SIZE, EXPECTED_HASH}, // Custom +#endif +}; + +static struct sdesc *init_sdesc(struct crypto_shash *alg) +{ + struct sdesc *sdesc; + int size; + + size = sizeof(struct shash_desc) + crypto_shash_descsize(alg); + sdesc = kzalloc(size, GFP_KERNEL); + if (!sdesc) + return ERR_PTR(-ENOMEM); + sdesc->shash.tfm = alg; + return sdesc; +} + +static int calc_hash(struct crypto_shash *alg, const unsigned char *data, + unsigned int datalen, unsigned char *digest) +{ + struct sdesc *sdesc; + int ret; + + sdesc = init_sdesc(alg); + if (IS_ERR(sdesc)) { + pr_info("can't alloc sdesc\n"); + return PTR_ERR(sdesc); + } + + ret = crypto_shash_digest(&sdesc->shash, data, datalen, digest); + kfree(sdesc); + return ret; +} + +static int ksu_sha256(const unsigned char *data, unsigned int datalen, + unsigned char *digest) +{ + struct crypto_shash *alg; + char *hash_alg_name = "sha256"; + int ret; + + alg = crypto_alloc_shash(hash_alg_name, 0, 0); + if (IS_ERR(alg)) { + pr_info("can't alloc alg %s\n", hash_alg_name); + return PTR_ERR(alg); + } + ret = calc_hash(alg, data, datalen, digest); + crypto_free_shash(alg); + return ret; +} + + +static struct dynamic_sign_key dynamic_sign = DYNAMIC_SIGN_DEFAULT_CONFIG; + +static bool check_dynamic_sign(struct file *fp, u32 size4, loff_t *pos, int *matched_index) +{ + struct dynamic_sign_key current_dynamic_key = dynamic_sign; + + if (ksu_get_dynamic_manager_config(¤t_dynamic_key.size, ¤t_dynamic_key.hash)) { + pr_debug("Using dynamic manager config: size=0x%x, hash=%.16s...\n", + current_dynamic_key.size, current_dynamic_key.hash); + } + + if (size4 != current_dynamic_key.size) { + return false; + } + +#define CERT_MAX_LENGTH 1024 + char cert[CERT_MAX_LENGTH]; + if (size4 > CERT_MAX_LENGTH) { + pr_info("cert length overlimit\n"); + return false; + } + + kernel_read(fp, cert, size4, pos); + + unsigned char digest[SHA256_DIGEST_SIZE]; + if (ksu_sha256(cert, size4, digest) < 0) { + pr_info("sha256 error\n"); + return false; + } + + char hash_str[SHA256_DIGEST_SIZE * 2 + 1]; + hash_str[SHA256_DIGEST_SIZE * 2] = '\0'; + bin2hex(hash_str, digest, SHA256_DIGEST_SIZE); + + pr_info("sha256: %s, expected: %s, index: dynamic\n", hash_str, current_dynamic_key.hash); + + if (strcmp(current_dynamic_key.hash, hash_str) == 0) { + if (matched_index) { + *matched_index = DYNAMIC_SIGN_INDEX; + } + return true; + } + + return false; +} + +static bool check_block(struct file *fp, u32 *size4, loff_t *pos, u32 *offset, int *matched_index) +{ + int i; + apk_sign_key_t sign_key; + bool signature_valid = false; + + kernel_read(fp, size4, 0x4, pos); // signer-sequence length + kernel_read(fp, size4, 0x4, pos); // signer length + kernel_read(fp, size4, 0x4, pos); // signed data length + + *offset += 0x4 * 3; + + kernel_read(fp, size4, 0x4, pos); // digests-sequence length + + *pos += *size4; + *offset += 0x4 + *size4; + + kernel_read(fp, size4, 0x4, pos); // certificates length + kernel_read(fp, size4, 0x4, pos); // certificate length + *offset += 0x4 * 2; + + if (ksu_is_dynamic_manager_enabled()) { + loff_t temp_pos = *pos; + if (check_dynamic_sign(fp, *size4, &temp_pos, matched_index)) { + *pos = temp_pos; + *offset += *size4; + return true; + } + } + + for (i = 0; i < ARRAY_SIZE(apk_sign_keys); i++) { + sign_key = apk_sign_keys[i]; + + if (*size4 != sign_key.size) + continue; + *offset += *size4; + +#define CERT_MAX_LENGTH 1024 + char cert[CERT_MAX_LENGTH]; + if (*size4 > CERT_MAX_LENGTH) { + pr_info("cert length overlimit\n"); + return false; + } + kernel_read(fp, cert, *size4, pos); + unsigned char digest[SHA256_DIGEST_SIZE]; + if (ksu_sha256(cert, *size4, digest) < 0 ) { + pr_info("sha256 error\n"); + return false; + } + + char hash_str[SHA256_DIGEST_SIZE * 2 + 1]; + hash_str[SHA256_DIGEST_SIZE * 2] = '\0'; + + bin2hex(hash_str, digest, SHA256_DIGEST_SIZE); + pr_info("sha256: %s, expected: %s, index: %d\n", hash_str, sign_key.sha256, i); + + if (strcmp(sign_key.sha256, hash_str) == 0) { + signature_valid = true; + if (matched_index) { + *matched_index = i; + } + break; + } + } + return signature_valid; +} + +struct zip_entry_header { + uint32_t signature; + uint16_t version; + uint16_t flags; + uint16_t compression; + uint16_t mod_time; + uint16_t mod_date; + uint32_t crc32; + uint32_t compressed_size; + uint32_t uncompressed_size; + uint16_t file_name_length; + uint16_t extra_field_length; +} __attribute__((packed)); + +// This is a necessary but not sufficient condition, but it is enough for us +static bool has_v1_signature_file(struct file *fp) +{ + struct zip_entry_header header; + const char MANIFEST[] = "META-INF/MANIFEST.MF"; + + loff_t pos = 0; + + while (kernel_read(fp, &header, + sizeof(struct zip_entry_header), &pos) == + sizeof(struct zip_entry_header)) { + if (header.signature != 0x04034b50) { + // ZIP magic: 'PK' + return false; + } + // Read the entry file name + if (header.file_name_length == sizeof(MANIFEST) - 1) { + char fileName[sizeof(MANIFEST)]; + kernel_read(fp, fileName, + header.file_name_length, &pos); + fileName[header.file_name_length] = '\0'; + + // Check if the entry matches META-INF/MANIFEST.MF + if (strncmp(MANIFEST, fileName, sizeof(MANIFEST) - 1) == + 0) { + return true; + } + } else { + // Skip the entry file name + pos += header.file_name_length; + } + + // Skip to the next entry + pos += header.extra_field_length + header.compressed_size; + } + + return false; +} + +static __always_inline bool check_v2_signature(char *path, bool check_multi_manager, int *signature_index) +{ + unsigned char buffer[0x11] = { 0 }; + u32 size4; + u64 size8, size_of_block; + + loff_t pos; + + bool v2_signing_valid = false; + int v2_signing_blocks = 0; + bool v3_signing_exist = false; + bool v3_1_signing_exist = false; + int matched_index = -1; + int i; + struct file *fp = filp_open(path, O_RDONLY, 0); + if (IS_ERR(fp)) { + pr_err("open %s error.\n", path); + return false; + } + + // If you want to check for multi-manager APK signing, but dynamic managering is not enabled, skip + if (check_multi_manager && !ksu_is_dynamic_manager_enabled()) { + filp_close(fp, 0); + return 0; + } + + // disable inotify for this file + fp->f_mode |= FMODE_NONOTIFY; + + // https://en.wikipedia.org/wiki/Zip_(file_format)#End_of_central_directory_record_(EOCD) + for (i = 0;; ++i) { + unsigned short n; + pos = generic_file_llseek(fp, -i - 2, SEEK_END); + kernel_read(fp, &n, 2, &pos); + if (n == i) { + pos -= 22; + kernel_read(fp, &size4, 4, &pos); + if ((size4 ^ 0xcafebabeu) == 0xccfbf1eeu) { + break; + } + } + if (i == 0xffff) { + pr_info("error: cannot find eocd\n"); + goto clean; + } + } + + pos += 12; + // offset + kernel_read(fp, &size4, 0x4, &pos); + pos = size4 - 0x18; + + kernel_read(fp, &size8, 0x8, &pos); + kernel_read(fp, buffer, 0x10, &pos); + if (strcmp((char *)buffer, "APK Sig Block 42")) { + goto clean; + } + + pos = size4 - (size8 + 0x8); + kernel_read(fp, &size_of_block, 0x8, &pos); + if (size_of_block != size8) { + goto clean; + } + + int loop_count = 0; + while (loop_count++ < 10) { + uint32_t id; + uint32_t offset; + kernel_read(fp, &size8, 0x8, + &pos); // sequence length + if (size8 == size_of_block) { + break; + } + kernel_read(fp, &id, 0x4, &pos); // id + offset = 4; + if (id == 0x7109871au) { + v2_signing_blocks++; + bool result = check_block(fp, &size4, &pos, &offset, &matched_index); + if (result) { + v2_signing_valid = true; + } + } else if (id == 0xf05368c0u) { + // http://aospxref.com/android-14.0.0_r2/xref/frameworks/base/core/java/android/util/apk/ApkSignatureSchemeV3Verifier.java#73 + v3_signing_exist = true; + } else if (id == 0x1b93ad61u) { + // http://aospxref.com/android-14.0.0_r2/xref/frameworks/base/core/java/android/util/apk/ApkSignatureSchemeV3Verifier.java#74 + v3_1_signing_exist = true; + } else { +#ifdef CONFIG_KSU_DEBUG + pr_info("Unknown id: 0x%08x\n", id); +#endif + } + pos += (size8 - offset); + } + + if (v2_signing_blocks != 1) { +#ifdef CONFIG_KSU_DEBUG + pr_err("Unexpected v2 signature count: %d\n", + v2_signing_blocks); +#endif + v2_signing_valid = false; + } + + if (v2_signing_valid) { + int has_v1_signing = has_v1_signature_file(fp); + if (has_v1_signing) { + pr_err("Unexpected v1 signature scheme found!\n"); + filp_close(fp, 0); + return false; + } + } +clean: + filp_close(fp, 0); + + if (v3_signing_exist || v3_1_signing_exist) { +#ifdef CONFIG_KSU_DEBUG + pr_err("Unexpected v3 signature scheme found!\n"); +#endif + return false; + } + + if (v2_signing_valid) { + if (signature_index) { + *signature_index = matched_index; + } + + if (check_multi_manager) { + // 0: ShirkNeko/SukiSU, DYNAMIC_SIGN_INDEX : Dynamic Sign + if (matched_index == 0 || matched_index == DYNAMIC_SIGN_INDEX) { + pr_info("Multi-manager APK detected (dynamic_manager enabled): signature_index=%d\n", matched_index); + return true; + } + return false; + } else { + // Common manager check: any valid signature will do + return true; + } + } + return false; +} + +#ifdef CONFIG_KSU_DEBUG + +int ksu_debug_manager_uid = -1; + +#include "manager.h" + +static int set_expected_size(const char *val, const struct kernel_param *kp) +{ + int rv = param_set_uint(val, kp); + ksu_set_manager_uid(ksu_debug_manager_uid); + pr_info("ksu_manager_uid set to %d\n", ksu_debug_manager_uid); + return rv; +} + +static struct kernel_param_ops expected_size_ops = { + .set = set_expected_size, + .get = param_get_uint, +}; + +module_param_cb(ksu_debug_manager_uid, &expected_size_ops, + &ksu_debug_manager_uid, S_IRUSR | S_IWUSR); + +#endif + +bool is_manager_apk(char *path) +{ + return check_v2_signature(path, false, NULL); +} + +bool is_dynamic_manager_apk(char *path, int *signature_index) +{ + return check_v2_signature(path, true, signature_index); +} \ No newline at end of file diff --git a/kernel/apk_sign.h b/kernel/apk_sign.h new file mode 100644 index 0000000..06c4461 --- /dev/null +++ b/kernel/apk_sign.h @@ -0,0 +1,11 @@ +#ifndef __KSU_H_APK_V2_SIGN +#define __KSU_H_APK_V2_SIGN + +#include +#include "ksu.h" + +bool is_manager_apk(char *path); + +bool is_dynamic_manager_apk(char *path, int *signature_index); + +#endif diff --git a/kernel/app_profile.c b/kernel/app_profile.c new file mode 100644 index 0000000..00cd9c5 --- /dev/null +++ b/kernel/app_profile.c @@ -0,0 +1,303 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "objsec.h" + +#include "allowlist.h" +#include "app_profile.h" +#include "klog.h" // IWYU pragma: keep +#include "selinux/selinux.h" +#include "syscall_hook_manager.h" +#include "sucompat.h" + +#include "sulog.h" + +#if LINUX_VERSION_CODE >= KERNEL_VERSION (6, 7, 0) + static struct group_info root_groups = { .usage = REFCOUNT_INIT(2), }; +#else + static struct group_info root_groups = { .usage = ATOMIC_INIT(2) }; +#endif + +static void setup_groups(struct root_profile *profile, struct cred *cred) +{ + if (profile->groups_count > KSU_MAX_GROUPS) { + pr_warn("Failed to setgroups, too large group: %d!\n", + profile->uid); + return; + } + + if (profile->groups_count == 1 && profile->groups[0] == 0) { + // setgroup to root and return early. + if (cred->group_info) + put_group_info(cred->group_info); + cred->group_info = get_group_info(&root_groups); + return; + } + + u32 ngroups = profile->groups_count; + struct group_info *group_info = groups_alloc(ngroups); + if (!group_info) { + pr_warn("Failed to setgroups, ENOMEM for: %d\n", profile->uid); + return; + } + + int i; + for (i = 0; i < ngroups; i++) { + gid_t gid = profile->groups[i]; + kgid_t kgid = make_kgid(current_user_ns(), gid); + if (!gid_valid(kgid)) { + pr_warn("Failed to setgroups, invalid gid: %d\n", gid); + put_group_info(group_info); + return; + } + group_info->gid[i] = kgid; + } + + groups_sort(group_info); + set_groups(cred, group_info); + put_group_info(group_info); +} + +void disable_seccomp(void) +{ + assert_spin_locked(¤t->sighand->siglock); + // disable seccomp +#if defined(CONFIG_GENERIC_ENTRY) && \ + LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) + clear_syscall_work(SECCOMP); +#else + clear_thread_flag(TIF_SECCOMP); +#endif + +#ifdef CONFIG_SECCOMP + current->seccomp.mode = 0; + current->seccomp.filter = NULL; + atomic_set(¤t->seccomp.filter_count, 0); +#else +#endif +} + +void escape_with_root_profile(void) +{ + struct cred *cred; + struct task_struct *p = current; + struct task_struct *t; + + cred = prepare_creds(); + if (!cred) { + pr_warn("prepare_creds failed!\n"); + return; + } + + if (cred->euid.val == 0) { + pr_warn("Already root, don't escape!\n"); +#if __SULOG_GATE + ksu_sulog_report_su_grant(current_euid().val, NULL, "escape_to_root_failed"); +#endif + abort_creds(cred); + return; + } + + struct root_profile *profile = ksu_get_root_profile(cred->uid.val); + + cred->uid.val = profile->uid; + cred->suid.val = profile->uid; + cred->euid.val = profile->uid; + cred->fsuid.val = profile->uid; + + cred->gid.val = profile->gid; + cred->fsgid.val = profile->gid; + cred->sgid.val = profile->gid; + cred->egid.val = profile->gid; + cred->securebits = 0; + + BUILD_BUG_ON(sizeof(profile->capabilities.effective) != + sizeof(kernel_cap_t)); + + // setup capabilities + // we need CAP_DAC_READ_SEARCH becuase `/data/adb/ksud` is not accessible for non root process + // we add it here but don't add it to cap_inhertiable, it would be dropped automaticly after exec! + u64 cap_for_ksud = + profile->capabilities.effective | CAP_DAC_READ_SEARCH; + memcpy(&cred->cap_effective, &cap_for_ksud, + sizeof(cred->cap_effective)); + memcpy(&cred->cap_permitted, &profile->capabilities.effective, + sizeof(cred->cap_permitted)); + memcpy(&cred->cap_bset, &profile->capabilities.effective, + sizeof(cred->cap_bset)); + + setup_groups(profile, cred); + + commit_creds(cred); + + // Refer to kernel/seccomp.c: seccomp_set_mode_strict + // When disabling Seccomp, ensure that current->sighand->siglock is held during the operation. + spin_lock_irq(¤t->sighand->siglock); + disable_seccomp(); + spin_unlock_irq(¤t->sighand->siglock); + + setup_selinux(profile->selinux_domain); +#if __SULOG_GATE + ksu_sulog_report_su_grant(current_euid().val, NULL, "escape_to_root"); +#endif + + for_each_thread (p, t) { + ksu_set_task_tracepoint_flag(t); + } +} + +#ifdef CONFIG_KSU_MANUAL_SU + +#include "ksud.h" + +#ifndef DEVPTS_SUPER_MAGIC +#define DEVPTS_SUPER_MAGIC 0x1cd1 +#endif + +static int __manual_su_handle_devpts(struct inode *inode) +{ + if (!current->mm) { + return 0; + } + + uid_t uid = current_uid().val; + if (uid % 100000 < 10000) { + // not untrusted_app, ignore it + return 0; + } + + if (likely(!ksu_is_allow_uid_for_current(uid))) + return 0; + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 1, 0) || defined(KSU_OPTIONAL_SELINUX_INODE) + struct inode_security_struct *sec = selinux_inode(inode); +#else + struct inode_security_struct *sec = + (struct inode_security_struct *)inode->i_security; +#endif + if (ksu_file_sid && sec) + sec->sid = ksu_file_sid; + + return 0; +} + +static void disable_seccomp_for_task(struct task_struct *tsk) +{ + assert_spin_locked(&tsk->sighand->siglock); +#ifdef CONFIG_SECCOMP + if (tsk->seccomp.mode == SECCOMP_MODE_DISABLED && !tsk->seccomp.filter) + return; +#endif + clear_tsk_thread_flag(tsk, TIF_SECCOMP); +#ifdef CONFIG_SECCOMP + tsk->seccomp.mode = SECCOMP_MODE_DISABLED; + if (tsk->seccomp.filter) { +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 10, 0) + seccomp_filter_release(tsk); +#else + put_seccomp_filter(tsk); + tsk->seccomp.filter = NULL; +#endif + } +#endif +} + +void escape_to_root_for_cmd_su(uid_t target_uid, pid_t target_pid) +{ + struct cred *newcreds; + struct task_struct *target_task; + unsigned long flags; + struct task_struct *p = current; + struct task_struct *t; + + pr_info("cmd_su: escape_to_root_for_cmd_su called for UID: %d, PID: %d\n", target_uid, target_pid); + + // Find target task by PID + rcu_read_lock(); + target_task = pid_task(find_vpid(target_pid), PIDTYPE_PID); + if (!target_task) { + rcu_read_unlock(); + pr_err("cmd_su: target task not found for PID: %d\n", target_pid); +#if __SULOG_GATE + ksu_sulog_report_su_grant(target_uid, "cmd_su", "target_not_found"); +#endif + return; + } + get_task_struct(target_task); + rcu_read_unlock(); + + if (task_uid(target_task).val == 0) { + pr_warn("cmd_su: target task is already root, PID: %d\n", target_pid); + put_task_struct(target_task); + return; + } + + newcreds = prepare_kernel_cred(target_task); + if (newcreds == NULL) { + pr_err("cmd_su: failed to allocate new cred for PID: %d\n", target_pid); +#if __SULOG_GATE + ksu_sulog_report_su_grant(target_uid, "cmd_su", "cred_alloc_failed"); +#endif + put_task_struct(target_task); + return; + } + + struct root_profile *profile = ksu_get_root_profile(target_uid); + + newcreds->uid.val = profile->uid; + newcreds->suid.val = profile->uid; + newcreds->euid.val = profile->uid; + newcreds->fsuid.val = profile->uid; + + newcreds->gid.val = profile->gid; + newcreds->fsgid.val = profile->gid; + newcreds->sgid.val = profile->gid; + newcreds->egid.val = profile->gid; + newcreds->securebits = 0; + + u64 cap_for_cmd_su = profile->capabilities.effective | CAP_DAC_READ_SEARCH | CAP_SETUID | CAP_SETGID; + memcpy(&newcreds->cap_effective, &cap_for_cmd_su, sizeof(newcreds->cap_effective)); + memcpy(&newcreds->cap_permitted, &profile->capabilities.effective, sizeof(newcreds->cap_permitted)); + memcpy(&newcreds->cap_bset, &profile->capabilities.effective, sizeof(newcreds->cap_bset)); + + setup_groups(profile, newcreds); + task_lock(target_task); + + const struct cred *old_creds = get_task_cred(target_task); + + rcu_assign_pointer(target_task->real_cred, newcreds); + rcu_assign_pointer(target_task->cred, get_cred(newcreds)); + task_unlock(target_task); + + if (target_task->sighand) { + spin_lock_irqsave(&target_task->sighand->siglock, flags); + disable_seccomp_for_task(target_task); + spin_unlock_irqrestore(&target_task->sighand->siglock, flags); + } + + setup_selinux(profile->selinux_domain); + put_cred(old_creds); + wake_up_process(target_task); + + if (target_task->signal->tty) { + struct inode *inode = target_task->signal->tty->driver_data; + if (inode && inode->i_sb->s_magic == DEVPTS_SUPER_MAGIC) { + __manual_su_handle_devpts(inode); + } + } + + put_task_struct(target_task); +#if __SULOG_GATE + ksu_sulog_report_su_grant(target_uid, "cmd_su", "manual_escalation"); +#endif + for_each_thread (p, t) { + ksu_set_task_tracepoint_flag(t); + } + pr_info("cmd_su: privilege escalation completed for UID: %d, PID: %d\n", target_uid, target_pid); +} +#endif diff --git a/kernel/app_profile.h b/kernel/app_profile.h new file mode 100644 index 0000000..871abb6 --- /dev/null +++ b/kernel/app_profile.h @@ -0,0 +1,70 @@ +#ifndef __KSU_H_APP_PROFILE +#define __KSU_H_APP_PROFILE + +#include + +// Forward declarations +struct cred; + +#define KSU_APP_PROFILE_VER 2 +#define KSU_MAX_PACKAGE_NAME 256 +// NGROUPS_MAX for Linux is 65535 generally, but we only supports 32 groups. +#define KSU_MAX_GROUPS 32 +#define KSU_SELINUX_DOMAIN 64 + +struct root_profile { + int32_t uid; + int32_t gid; + + int32_t groups_count; + int32_t groups[KSU_MAX_GROUPS]; + + // kernel_cap_t is u32[2] for capabilities v3 + struct { + u64 effective; + u64 permitted; + u64 inheritable; + } capabilities; + + char selinux_domain[KSU_SELINUX_DOMAIN]; + + int32_t namespaces; +}; + +struct non_root_profile { + bool umount_modules; +}; + +struct app_profile { + // It may be utilized for backward compatibility, although we have never explicitly made any promises regarding this. + u32 version; + + // this is usually the package of the app, but can be other value for special apps + char key[KSU_MAX_PACKAGE_NAME]; + int32_t current_uid; + bool allow_su; + + union { + struct { + bool use_default; + char template_name[KSU_MAX_PACKAGE_NAME]; + + struct root_profile profile; + } rp_config; + + struct { + bool use_default; + + struct non_root_profile profile; + } nrp_config; + }; +}; + +// Escalate current process to root with the appropriate profile +void escape_with_root_profile(void); + +void escape_to_root_for_cmd_su(uid_t target_uid, pid_t target_pid); + +void disable_seccomp(void); + +#endif diff --git a/kernel/arch.h b/kernel/arch.h new file mode 100644 index 0000000..ee2b16c --- /dev/null +++ b/kernel/arch.h @@ -0,0 +1,68 @@ +#ifndef __KSU_H_ARCH +#define __KSU_H_ARCH + +#include + +#if defined(__aarch64__) + +#define __PT_PARM1_REG regs[0] +#define __PT_PARM2_REG regs[1] +#define __PT_PARM3_REG regs[2] +#define __PT_SYSCALL_PARM4_REG regs[3] +#define __PT_CCALL_PARM4_REG regs[3] +#define __PT_PARM5_REG regs[4] +#define __PT_PARM6_REG regs[5] +#define __PT_RET_REG regs[30] +#define __PT_FP_REG regs[29] /* Works only with CONFIG_FRAME_POINTER */ +#define __PT_RC_REG regs[0] +#define __PT_SP_REG sp +#define __PT_IP_REG pc + +#define REBOOT_SYMBOL "__arm64_sys_reboot" +#define SYS_READ_SYMBOL "__arm64_sys_read" +#define SYS_EXECVE_SYMBOL "__arm64_sys_execve" + +#elif defined(__x86_64__) + +#define __PT_PARM1_REG di +#define __PT_PARM2_REG si +#define __PT_PARM3_REG dx +/* syscall uses r10 for PARM4 */ +#define __PT_SYSCALL_PARM4_REG r10 +#define __PT_CCALL_PARM4_REG cx +#define __PT_PARM5_REG r8 +#define __PT_PARM6_REG r9 +#define __PT_RET_REG sp +#define __PT_FP_REG bp +#define __PT_RC_REG ax +#define __PT_SP_REG sp +#define __PT_IP_REG ip +#define REBOOT_SYMBOL "__x64_sys_reboot" +#define SYS_READ_SYMBOL "__x64_sys_read" +#define SYS_EXECVE_SYMBOL "__x64_sys_execve" + +#else +#error "Unsupported arch" +#endif + +/* allow some architecutres to override `struct pt_regs` */ +#ifndef __PT_REGS_CAST +#define __PT_REGS_CAST(x) (x) +#endif + +#define PT_REGS_PARM1(x) (__PT_REGS_CAST(x)->__PT_PARM1_REG) +#define PT_REGS_PARM2(x) (__PT_REGS_CAST(x)->__PT_PARM2_REG) +#define PT_REGS_PARM3(x) (__PT_REGS_CAST(x)->__PT_PARM3_REG) +#define PT_REGS_SYSCALL_PARM4(x) (__PT_REGS_CAST(x)->__PT_SYSCALL_PARM4_REG) +#define PT_REGS_CCALL_PARM4(x) (__PT_REGS_CAST(x)->__PT_CCALL_PARM4_REG) +#define PT_REGS_PARM5(x) (__PT_REGS_CAST(x)->__PT_PARM5_REG) +#define PT_REGS_PARM6(x) (__PT_REGS_CAST(x)->__PT_PARM6_REG) +#define PT_REGS_RET(x) (__PT_REGS_CAST(x)->__PT_RET_REG) +#define PT_REGS_FP(x) (__PT_REGS_CAST(x)->__PT_FP_REG) +#define PT_REGS_RC(x) (__PT_REGS_CAST(x)->__PT_RC_REG) +#define PT_REGS_SP(x) (__PT_REGS_CAST(x)->__PT_SP_REG) +#define PT_REGS_IP(x) (__PT_REGS_CAST(x)->__PT_IP_REG) + +#define PT_REAL_REGS(regs) ((struct pt_regs *)PT_REGS_PARM1(regs)) + +#endif diff --git a/kernel/dynamic_manager.c b/kernel/dynamic_manager.c new file mode 100644 index 0000000..96bc710 --- /dev/null +++ b/kernel/dynamic_manager.c @@ -0,0 +1,504 @@ +#include +#include +#include +#include +#include +#include +#include +#ifdef CONFIG_KSU_DEBUG +#include +#endif +#include +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) +#include +#else +#include +#endif + +#include "dynamic_manager.h" +#include "klog.h" // IWYU pragma: keep +#include "manager.h" + +#define MAX_MANAGERS 2 + +// Dynamic sign configuration +static struct dynamic_manager_config dynamic_manager = { + .size = 0x300, + .hash = "0000000000000000000000000000000000000000000000000000000000000000", + .is_set = 0 +}; + +// Multi-manager state +static struct manager_info active_managers[MAX_MANAGERS]; +static DEFINE_SPINLOCK(managers_lock); +static DEFINE_SPINLOCK(dynamic_manager_lock); + +// Work queues for persistent storage +static struct work_struct save_dynamic_manager_work; +static struct work_struct load_dynamic_manager_work; +static struct work_struct clear_dynamic_manager_work; + +bool ksu_is_dynamic_manager_enabled(void) +{ + unsigned long flags; + bool enabled; + + spin_lock_irqsave(&dynamic_manager_lock, flags); + enabled = dynamic_manager.is_set; + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + + return enabled; +} + +void ksu_add_manager(uid_t uid, int signature_index) +{ + unsigned long flags; + int i; + + if (!ksu_is_dynamic_manager_enabled()) { + pr_info("Dynamic sign not enabled, skipping multi-manager add\n"); + return; + } + + spin_lock_irqsave(&managers_lock, flags); + + // Check if manager already exists and update + for (i = 0; i < MAX_MANAGERS; i++) { + if (active_managers[i].is_active && active_managers[i].uid == uid) { + active_managers[i].signature_index = signature_index; + spin_unlock_irqrestore(&managers_lock, flags); + pr_info("Updated manager uid=%d, signature_index=%d\n", uid, signature_index); + return; + } + } + + // Find free slot for new manager + for (i = 0; i < MAX_MANAGERS; i++) { + if (!active_managers[i].is_active) { + active_managers[i].uid = uid; + active_managers[i].signature_index = signature_index; + active_managers[i].is_active = true; + spin_unlock_irqrestore(&managers_lock, flags); + pr_info("Added manager uid=%d, signature_index=%d\n", uid, signature_index); + return; + } + } + + spin_unlock_irqrestore(&managers_lock, flags); + pr_warn("Failed to add manager, no free slots\n"); +} + +void ksu_remove_manager(uid_t uid) +{ + unsigned long flags; + int i; + + if (!ksu_is_dynamic_manager_enabled()) { + return; + } + + spin_lock_irqsave(&managers_lock, flags); + + for (i = 0; i < MAX_MANAGERS; i++) { + if (active_managers[i].is_active && active_managers[i].uid == uid) { + active_managers[i].is_active = false; + pr_info("Removed manager uid=%d\n", uid); + break; + } + } + + spin_unlock_irqrestore(&managers_lock, flags); +} + +bool ksu_is_any_manager(uid_t uid) +{ + unsigned long flags; + bool is_manager = false; + int i; + + if (!ksu_is_dynamic_manager_enabled()) { + return false; + } + + spin_lock_irqsave(&managers_lock, flags); + + for (i = 0; i < MAX_MANAGERS; i++) { + if (active_managers[i].is_active && active_managers[i].uid == uid) { + is_manager = true; + break; + } + } + + spin_unlock_irqrestore(&managers_lock, flags); + return is_manager; +} + +int ksu_get_manager_signature_index(uid_t uid) +{ + unsigned long flags; + int signature_index = -1; + int i; + + // Check traditional manager first + if (ksu_manager_uid != KSU_INVALID_UID && uid == ksu_manager_uid) { + return DYNAMIC_SIGN_INDEX; + } + + if (!ksu_is_dynamic_manager_enabled()) { + return -1; + } + + spin_lock_irqsave(&managers_lock, flags); + + for (i = 0; i < MAX_MANAGERS; i++) { + if (active_managers[i].is_active && active_managers[i].uid == uid) { + signature_index = active_managers[i].signature_index; + break; + } + } + + spin_unlock_irqrestore(&managers_lock, flags); + return signature_index; +} + +static void clear_dynamic_manager(void) +{ + unsigned long flags; + int i; + + spin_lock_irqsave(&managers_lock, flags); + + for (i = 0; i < MAX_MANAGERS; i++) { + if (active_managers[i].is_active) { + pr_info("Clearing dynamic manager uid=%d (signature_index=%d) for rescan\n", + active_managers[i].uid, active_managers[i].signature_index); + active_managers[i].is_active = false; + } + } + + spin_unlock_irqrestore(&managers_lock, flags); +} + +int ksu_get_active_managers(struct manager_list_info *info) +{ + unsigned long flags; + int i, count = 0; + + if (!info) { + return -EINVAL; + } + + // Add traditional manager first + if (ksu_manager_uid != KSU_INVALID_UID && count < 2) { + info->managers[count].uid = ksu_manager_uid; + info->managers[count].signature_index = 0; + count++; + } + + // Add dynamic managers + if (ksu_is_dynamic_manager_enabled()) { + spin_lock_irqsave(&managers_lock, flags); + + for (i = 0; i < MAX_MANAGERS && count < 2; i++) { + if (active_managers[i].is_active) { + info->managers[count].uid = active_managers[i].uid; + info->managers[count].signature_index = active_managers[i].signature_index; + count++; + } + } + + spin_unlock_irqrestore(&managers_lock, flags); + } + + info->count = count; + return 0; +} + +static void do_save_dynamic_manager(struct work_struct *work) +{ + u32 magic = DYNAMIC_MANAGER_FILE_MAGIC; + u32 version = DYNAMIC_MANAGER_FILE_VERSION; + struct dynamic_manager_config config_to_save; + loff_t off = 0; + unsigned long flags; + struct file *fp; + + spin_lock_irqsave(&dynamic_manager_lock, flags); + config_to_save = dynamic_manager; + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + + if (!config_to_save.is_set) { + pr_info("Dynamic sign config not set, skipping save\n"); + return; + } + + fp = filp_open(KERNEL_SU_DYNAMIC_MANAGER, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (IS_ERR(fp)) { + pr_err("save_dynamic_manager create file failed: %ld\n", PTR_ERR(fp)); + return; + } + + if (kernel_write(fp, &magic, sizeof(magic), &off) != sizeof(magic)) { + pr_err("save_dynamic_manager write magic failed.\n"); + goto exit; + } + + if (kernel_write(fp, &version, sizeof(version), &off) != sizeof(version)) { + pr_err("save_dynamic_manager write version failed.\n"); + goto exit; + } + + if (kernel_write(fp, &config_to_save, sizeof(config_to_save), &off) != sizeof(config_to_save)) { + pr_err("save_dynamic_manager write config failed.\n"); + goto exit; + } + + pr_info("Dynamic sign config saved successfully\n"); + +exit: + filp_close(fp, 0); +} + +static void do_load_dynamic_manager(struct work_struct *work) +{ + loff_t off = 0; + ssize_t ret = 0; + struct file *fp = NULL; + u32 magic; + u32 version; + struct dynamic_manager_config loaded_config; + unsigned long flags; + int i; + + fp = filp_open(KERNEL_SU_DYNAMIC_MANAGER, O_RDONLY, 0); + if (IS_ERR(fp)) { + if (PTR_ERR(fp) == -ENOENT) { + pr_info("No saved dynamic manager config found\n"); + } else { + pr_err("load_dynamic_manager open file failed: %ld\n", PTR_ERR(fp)); + } + return; + } + + if (kernel_read(fp, &magic, sizeof(magic), &off) != sizeof(magic) || + magic != DYNAMIC_MANAGER_FILE_MAGIC) { + pr_err("dynamic manager file invalid magic: %x!\n", magic); + goto exit; + } + + if (kernel_read(fp, &version, sizeof(version), &off) != sizeof(version)) { + pr_err("dynamic manager read version failed\n"); + goto exit; + } + + pr_info("dynamic manager file version: %d\n", version); + + ret = kernel_read(fp, &loaded_config, sizeof(loaded_config), &off); + if (ret <= 0) { + pr_info("load_dynamic_manager read err: %zd\n", ret); + goto exit; + } + + if (ret != sizeof(loaded_config)) { + pr_err("load_dynamic_manager read incomplete config: %zd/%zu\n", ret, sizeof(loaded_config)); + goto exit; + } + + if (loaded_config.size < 0x100 || loaded_config.size > 0x1000) { + pr_err("Invalid saved config size: 0x%x\n", loaded_config.size); + goto exit; + } + + if (strlen(loaded_config.hash) != 64) { + pr_err("Invalid saved config hash length: %zu\n", strlen(loaded_config.hash)); + goto exit; + } + + // Validate hash format + for (i = 0; i < 64; i++) { + char c = loaded_config.hash[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + pr_err("Invalid saved config hash character at position %d: %c\n", i, c); + goto exit; + } + } + + spin_lock_irqsave(&dynamic_manager_lock, flags); + dynamic_manager = loaded_config; + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + + pr_info("Dynamic sign config loaded: size=0x%x, hash=%.16s...\n", + loaded_config.size, loaded_config.hash); + +exit: + filp_close(fp, 0); +} + +static bool persistent_dynamic_manager(void) +{ + return ksu_queue_work(&save_dynamic_manager_work); +} + +static void do_clear_dynamic_manager(struct work_struct *work) +{ + loff_t off = 0; + struct file *fp; + char zero_buffer[512]; + + memset(zero_buffer, 0, sizeof(zero_buffer)); + + fp = filp_open(KERNEL_SU_DYNAMIC_MANAGER, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (IS_ERR(fp)) { + pr_err("clear_dynamic_manager create file failed: %ld\n", PTR_ERR(fp)); + return; + } + + // Write null bytes to overwrite the file content + if (kernel_write(fp, zero_buffer, sizeof(zero_buffer), &off) != sizeof(zero_buffer)) { + pr_err("clear_dynamic_manager write null bytes failed.\n"); + } else { + pr_info("Dynamic sign config file cleared successfully\n"); + } + + filp_close(fp, 0); +} + +static bool clear_dynamic_manager_file(void) +{ + return ksu_queue_work(&clear_dynamic_manager_work); +} + +int ksu_handle_dynamic_manager(struct dynamic_manager_user_config *config) +{ + unsigned long flags; + int ret = 0; + int i; + + if (!config) { + return -EINVAL; + } + + switch (config->operation) { + case DYNAMIC_MANAGER_OP_SET: + if (config->size < 0x100 || config->size > 0x1000) { + pr_err("invalid size: 0x%x\n", config->size); + return -EINVAL; + } + + if (strlen(config->hash) != 64) { + pr_err("invalid hash length: %zu\n", strlen(config->hash)); + return -EINVAL; + } + + // Validate hash format + for (i = 0; i < 64; i++) { + char c = config->hash[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + pr_err("invalid hash character at position %d: %c\n", i, c); + return -EINVAL; + } + } + + spin_lock_irqsave(&dynamic_manager_lock, flags); + dynamic_manager.size = config->size; +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0) + strscpy(dynamic_manager.hash, config->hash, sizeof(dynamic_manager.hash)); +#else + strlcpy(dynamic_manager.hash, config->hash, sizeof(dynamic_manager.hash)); +#endif + dynamic_manager.is_set = 1; + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + + persistent_dynamic_manager(); + pr_info("dynamic manager updated: size=0x%x, hash=%.16s... (multi-manager enabled)\n", + config->size, config->hash); + break; + + case DYNAMIC_MANAGER_OP_GET: + spin_lock_irqsave(&dynamic_manager_lock, flags); + if (dynamic_manager.is_set) { + config->size = dynamic_manager.size; +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0) + strscpy(config->hash, dynamic_manager.hash, sizeof(config->hash)); +#else + strlcpy(config->hash, dynamic_manager.hash, sizeof(config->hash)); +#endif + ret = 0; + } else { + ret = -ENODATA; + } + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + break; + + case DYNAMIC_MANAGER_OP_CLEAR: + spin_lock_irqsave(&dynamic_manager_lock, flags); + dynamic_manager.size = 0x300; + strcpy(dynamic_manager.hash, "0000000000000000000000000000000000000000000000000000000000000000"); + dynamic_manager.is_set = 0; + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + + // Clear only dynamic managers, preserve default manager + clear_dynamic_manager(); + + // Clear file using the same method as save + clear_dynamic_manager_file(); + + pr_info("Dynamic sign config cleared (multi-manager disabled)\n"); + break; + + default: + pr_err("Invalid dynamic manager operation: %d\n", config->operation); + return -EINVAL; + } + + return ret; +} + +bool ksu_load_dynamic_manager(void) +{ + return ksu_queue_work(&load_dynamic_manager_work); +} + +void ksu_dynamic_manager_init(void) +{ + int i; + + INIT_WORK(&save_dynamic_manager_work, do_save_dynamic_manager); + INIT_WORK(&load_dynamic_manager_work, do_load_dynamic_manager); + INIT_WORK(&clear_dynamic_manager_work, do_clear_dynamic_manager); + + // Initialize manager slots + for (i = 0; i < MAX_MANAGERS; i++) { + active_managers[i].is_active = false; + } + + ksu_load_dynamic_manager(); + + pr_info("Dynamic sign initialized with conditional multi-manager support\n"); +} + +void ksu_dynamic_manager_exit(void) +{ + clear_dynamic_manager(); + + // Save current config before exit + do_save_dynamic_manager(NULL); + pr_info("Dynamic sign exited with persistent storage\n"); +} + +// Get dynamic manager configuration for signature verification +bool ksu_get_dynamic_manager_config(unsigned int *size, const char **hash) +{ + unsigned long flags; + bool valid = false; + + spin_lock_irqsave(&dynamic_manager_lock, flags); + if (dynamic_manager.is_set) { + if (size) *size = dynamic_manager.size; + if (hash) *hash = dynamic_manager.hash; + valid = true; + } + spin_unlock_irqrestore(&dynamic_manager_lock, flags); + + return valid; +} \ No newline at end of file diff --git a/kernel/dynamic_manager.h b/kernel/dynamic_manager.h new file mode 100644 index 0000000..1b5a5cb --- /dev/null +++ b/kernel/dynamic_manager.h @@ -0,0 +1,51 @@ +#ifndef __KSU_H_DYNAMIC_MANAGER +#define __KSU_H_DYNAMIC_MANAGER + +#include +#include "ksu.h" + +#define DYNAMIC_MANAGER_FILE_MAGIC 0x7f445347 // 'DSG', u32 +#define DYNAMIC_MANAGER_FILE_VERSION 1 // u32 +#define KERNEL_SU_DYNAMIC_MANAGER "/data/adb/ksu/.dynamic_manager" +#define DYNAMIC_SIGN_INDEX 100 + +struct dynamic_sign_key { + unsigned int size; + const char *hash; +}; + +#define DYNAMIC_SIGN_DEFAULT_CONFIG { \ + .size = 0x300, \ + .hash = "0000000000000000000000000000000000000000000000000000000000000000" \ +} + +struct dynamic_manager_config { + unsigned int size; + char hash[65]; + int is_set; +}; + +struct manager_info { + uid_t uid; + int signature_index; + bool is_active; +}; + +// Dynamic sign operations +void ksu_dynamic_manager_init(void); +void ksu_dynamic_manager_exit(void); +int ksu_handle_dynamic_manager(struct dynamic_manager_user_config *config); +bool ksu_load_dynamic_manager(void); +bool ksu_is_dynamic_manager_enabled(void); + +// Multi-manager operations +void ksu_add_manager(uid_t uid, int signature_index); +void ksu_remove_manager(uid_t uid); +bool ksu_is_any_manager(uid_t uid); +int ksu_get_manager_signature_index(uid_t uid); +int ksu_get_active_managers(struct manager_list_info *info); + +// Configuration access for signature verification +bool ksu_get_dynamic_manager_config(unsigned int *size, const char **hash); + +#endif \ No newline at end of file diff --git a/kernel/embed_ksud.c b/kernel/embed_ksud.c new file mode 100644 index 0000000..24c4012 --- /dev/null +++ b/kernel/embed_ksud.c @@ -0,0 +1,5 @@ +// WARNING: THIS IS A STUB FILE +// This file will be regenerated by CI + +unsigned int ksud_size = 0; +const char ksud[0] = {}; diff --git a/kernel/export_symbol.txt b/kernel/export_symbol.txt new file mode 100644 index 0000000..1abd805 --- /dev/null +++ b/kernel/export_symbol.txt @@ -0,0 +1,2 @@ +register_kprobe +unregister_kprobe diff --git a/kernel/feature.c b/kernel/feature.c new file mode 100644 index 0000000..99277b5 --- /dev/null +++ b/kernel/feature.c @@ -0,0 +1,173 @@ +#include "feature.h" +#include "klog.h" // IWYU pragma: keep + +#include + +static const struct ksu_feature_handler *feature_handlers[KSU_FEATURE_MAX]; + +static DEFINE_MUTEX(feature_mutex); + +int ksu_register_feature_handler(const struct ksu_feature_handler *handler) +{ + if (!handler) { + pr_err("feature: register handler is NULL\n"); + return -EINVAL; + } + + if (handler->feature_id >= KSU_FEATURE_MAX) { + pr_err("feature: invalid feature_id %u\n", handler->feature_id); + return -EINVAL; + } + + if (!handler->get_handler && !handler->set_handler) { + pr_err("feature: no handler provided for feature %u\n", handler->feature_id); + return -EINVAL; + } + + mutex_lock(&feature_mutex); + + if (feature_handlers[handler->feature_id]) { + pr_warn("feature: handler for %u already registered, overwriting\n", + handler->feature_id); + } + + feature_handlers[handler->feature_id] = handler; + + pr_info("feature: registered handler for %s (id=%u)\n", + handler->name ? handler->name : "unknown", handler->feature_id); + + mutex_unlock(&feature_mutex); + return 0; +} + +int ksu_unregister_feature_handler(u32 feature_id) +{ + int ret = 0; + + if (feature_id >= KSU_FEATURE_MAX) { + pr_err("feature: invalid feature_id %u\n", feature_id); + return -EINVAL; + } + + mutex_lock(&feature_mutex); + + if (!feature_handlers[feature_id]) { + pr_warn("feature: no handler registered for %u\n", feature_id); + ret = -ENOENT; + goto out; + } + + feature_handlers[feature_id] = NULL; + + pr_info("feature: unregistered handler for id=%u\n", feature_id); + +out: + mutex_unlock(&feature_mutex); + return ret; +} + +int ksu_get_feature(u32 feature_id, u64 *value, bool *supported) +{ + int ret = 0; + const struct ksu_feature_handler *handler; + + if (feature_id >= KSU_FEATURE_MAX) { + pr_err("feature: invalid feature_id %u\n", feature_id); + return -EINVAL; + } + + if (!value || !supported) { + pr_err("feature: invalid parameters\n"); + return -EINVAL; + } + + mutex_lock(&feature_mutex); + + handler = feature_handlers[feature_id]; + + if (!handler) { + *supported = false; + *value = 0; + pr_debug("feature: feature %u not supported\n", feature_id); + goto out; + } + + *supported = true; + + if (!handler->get_handler) { + pr_warn("feature: no get_handler for feature %u\n", feature_id); + ret = -EOPNOTSUPP; + goto out; + } + + ret = handler->get_handler(value); + if (ret) { + pr_err("feature: get_handler for %u failed: %d\n", feature_id, ret); + } + +out: + mutex_unlock(&feature_mutex); + return ret; +} + +int ksu_set_feature(u32 feature_id, u64 value) +{ + int ret = 0; + const struct ksu_feature_handler *handler; + + if (feature_id >= KSU_FEATURE_MAX) { + pr_err("feature: invalid feature_id %u\n", feature_id); + return -EINVAL; + } + + mutex_lock(&feature_mutex); + + handler = feature_handlers[feature_id]; + + if (!handler) { + pr_err("feature: feature %u not registered\n", feature_id); + ret = -EOPNOTSUPP; + goto out; + } + + if (!handler->set_handler) { + pr_warn("feature: no set_handler for feature %u\n", feature_id); + ret = -EOPNOTSUPP; + goto out; + } + + ret = handler->set_handler(value); + if (ret) { + pr_err("feature: set_handler for %u failed: %d\n", feature_id, ret); + } + +out: + mutex_unlock(&feature_mutex); + return ret; +} + +void ksu_feature_init(void) +{ + int i; + + for (i = 0; i < KSU_FEATURE_MAX; i++) { + feature_handlers[i] = NULL; + } + + pr_info("feature: feature management initialized\n"); +} + +void ksu_feature_exit(void) +{ + int i; + + mutex_lock(&feature_mutex); + + for (i = 0; i < KSU_FEATURE_MAX; i++) { + feature_handlers[i] = NULL; + } + + mutex_unlock(&feature_mutex); + + pr_info("feature: feature management cleaned up\n"); +} diff --git a/kernel/feature.h b/kernel/feature.h new file mode 100644 index 0000000..9b63a0b --- /dev/null +++ b/kernel/feature.h @@ -0,0 +1,37 @@ +#ifndef __KSU_H_FEATURE +#define __KSU_H_FEATURE + +#include + +enum ksu_feature_id { + KSU_FEATURE_SU_COMPAT = 0, + KSU_FEATURE_KERNEL_UMOUNT = 1, + KSU_FEATURE_ENHANCED_SECURITY = 2, + KSU_FEATURE_SULOG = 3, + + KSU_FEATURE_MAX +}; + +typedef int (*ksu_feature_get_t)(u64 *value); +typedef int (*ksu_feature_set_t)(u64 value); + +struct ksu_feature_handler { + u32 feature_id; + const char *name; + ksu_feature_get_t get_handler; + ksu_feature_set_t set_handler; +}; + +int ksu_register_feature_handler(const struct ksu_feature_handler *handler); + +int ksu_unregister_feature_handler(u32 feature_id); + +int ksu_get_feature(u32 feature_id, u64 *value, bool *supported); + +int ksu_set_feature(u32 feature_id, u64 value); + +void ksu_feature_init(void); + +void ksu_feature_exit(void); + +#endif // __KSU_H_FEATURE diff --git a/kernel/file_wrapper.c b/kernel/file_wrapper.c new file mode 100644 index 0000000..d73cf5d --- /dev/null +++ b/kernel/file_wrapper.c @@ -0,0 +1,341 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "klog.h" // IWYU pragma: keep +#include "selinux/selinux.h" + +#include "file_wrapper.h" + +static loff_t ksu_wrapper_llseek(struct file *fp, loff_t off, int flags) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->llseek(data->orig, off, flags); +} + +static ssize_t ksu_wrapper_read(struct file *fp, char __user *ptr, size_t sz, loff_t *off) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->read(orig, ptr, sz, off); +} + +static ssize_t ksu_wrapper_write(struct file *fp, const char __user *ptr, size_t sz, loff_t *off) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->write(orig, ptr, sz, off); +} + +static ssize_t ksu_wrapper_read_iter(struct kiocb *iocb, struct iov_iter *iovi) { + struct ksu_file_wrapper* data = iocb->ki_filp->private_data; + struct file* orig = data->orig; + iocb->ki_filp = orig; + return orig->f_op->read_iter(iocb, iovi); +} + +static ssize_t ksu_wrapper_write_iter(struct kiocb *iocb, struct iov_iter *iovi) { + struct ksu_file_wrapper* data = iocb->ki_filp->private_data; + struct file* orig = data->orig; + iocb->ki_filp = orig; + return orig->f_op->write_iter(iocb, iovi); +} + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 1, 0) +static int ksu_wrapper_iopoll(struct kiocb *kiocb, struct io_comp_batch* icb, unsigned int v) { + struct ksu_file_wrapper* data = kiocb->ki_filp->private_data; + struct file* orig = data->orig; + kiocb->ki_filp = orig; + return orig->f_op->iopoll(kiocb, icb, v); +} +#else +static int ksu_wrapper_iopoll(struct kiocb *kiocb, bool spin) { + struct ksu_file_wrapper* data = kiocb->ki_filp->private_data; + struct file* orig = data->orig; + kiocb->ki_filp = orig; + return orig->f_op->iopoll(kiocb, spin); +} +#endif + +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) +static int ksu_wrapper_iterate (struct file *fp, struct dir_context *dc) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->iterate(orig, dc); +} +#endif + +static int ksu_wrapper_iterate_shared(struct file *fp, struct dir_context *dc) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->iterate_shared(orig, dc); +} + +static __poll_t ksu_wrapper_poll(struct file *fp, struct poll_table_struct *pts) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->poll(orig, pts); +} + +static long ksu_wrapper_unlocked_ioctl(struct file *fp, unsigned int cmd, unsigned long arg) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->unlocked_ioctl(orig, cmd, arg); +} + +static long ksu_wrapper_compat_ioctl(struct file *fp, unsigned int cmd, unsigned long arg) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->compat_ioctl(orig, cmd, arg); +} + +static int ksu_wrapper_mmap(struct file *fp, struct vm_area_struct * vma) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->mmap(orig, vma); +} + +// static unsigned long mmap_supported_flags {} + +static int ksu_wrapper_open(struct inode *ino, struct file *fp) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + struct inode *orig_ino = file_inode(orig); + return orig->f_op->open(orig_ino, orig); +} + +static int ksu_wrapper_flush(struct file *fp, fl_owner_t id) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->flush(orig, id); +} + + +static int ksu_wrapper_fsync(struct file *fp, loff_t off1, loff_t off2, int datasync) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->fsync(orig, off1, off2, datasync); +} + +static int ksu_wrapper_fasync(int arg, struct file *fp, int arg2) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->fasync(arg, orig, arg2); +} + +static int ksu_wrapper_lock(struct file *fp, int arg1, struct file_lock *fl) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + return orig->f_op->lock(orig, arg1, fl); +} + + +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) +static ssize_t ksu_wrapper_sendpage(struct file *fp, struct page *pg, int arg1, size_t sz, loff_t *off, int arg2) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->sendpage) { + return orig->f_op->sendpage(orig, pg, arg1, sz, off, arg2); + } + return -EINVAL; +} +#endif + +static unsigned long ksu_wrapper_get_unmapped_area(struct file *fp, unsigned long arg1, unsigned long arg2, unsigned long arg3, unsigned long arg4) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->get_unmapped_area) { + return orig->f_op->get_unmapped_area(orig, arg1, arg2, arg3, arg4); + } + return -EINVAL; +} + +// static int ksu_wrapper_check_flags(int arg) {} + +static int ksu_wrapper_flock(struct file *fp, int arg1, struct file_lock *fl) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->flock) { + return orig->f_op->flock(orig, arg1, fl); + } + return -EINVAL; +} + +static ssize_t ksu_wrapper_splice_write(struct pipe_inode_info * pii, struct file *fp, loff_t *off, size_t sz, unsigned int arg1) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->splice_write) { + return orig->f_op->splice_write(pii, orig, off, sz, arg1); + } + return -EINVAL; +} + +static ssize_t ksu_wrapper_splice_read(struct file *fp, loff_t *off, struct pipe_inode_info *pii, size_t sz, unsigned int arg1) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->splice_read) { + return orig->f_op->splice_read(orig, off, pii, sz, arg1); + } + return -EINVAL; +} + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 6, 0) +void ksu_wrapper_splice_eof(struct file *fp) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->splice_eof) { + return orig->f_op->splice_eof(orig); + } +} +#endif + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 12, 0) +static int ksu_wrapper_setlease(struct file *fp, int arg1, struct file_lease **fl, void **p) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->setlease) { + return orig->f_op->setlease(orig, arg1, fl, p); + } + return -EINVAL; +} +#elif LINUX_VERSION_CODE >= KERNEL_VERSION(6, 6, 0) +static int ksu_wrapper_setlease(struct file *fp, int arg1, struct file_lock **fl, void **p) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->setlease) { + return orig->f_op->setlease(orig, arg1, fl, p); + } + return -EINVAL; +} +#else +static int ksu_wrapper_setlease(struct file *fp, long arg1, struct file_lock **fl, void **p) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->setlease) { + return orig->f_op->setlease(orig, arg1, fl, p); + } + return -EINVAL; +} +#endif + +static long ksu_wrapper_fallocate(struct file *fp, int mode, loff_t offset, loff_t len) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->fallocate) { + return orig->f_op->fallocate(orig, mode, offset, len); + } + return -EINVAL; +} + +static void ksu_wrapper_show_fdinfo(struct seq_file *m, struct file *f) { + struct ksu_file_wrapper* data = f->private_data; + struct file* orig = data->orig; + if (orig->f_op->show_fdinfo) { + orig->f_op->show_fdinfo(m, orig); + } +} + +static ssize_t ksu_wrapper_copy_file_range(struct file *f1, loff_t off1, struct file *f2, + loff_t off2, size_t sz, unsigned int flags) { + // TODO: determine which file to use + struct ksu_file_wrapper* data = f1->private_data; + struct file* orig = data->orig; + if (orig->f_op->copy_file_range) { + return orig->f_op->copy_file_range(orig, off1, f2, off2, sz, flags); + } + return -EINVAL; +} + +static loff_t ksu_wrapper_remap_file_range(struct file *file_in, loff_t pos_in, + struct file *file_out, loff_t pos_out, + loff_t len, unsigned int remap_flags) { + // TODO: determine which file to use + struct ksu_file_wrapper* data = file_in->private_data; + struct file* orig = data->orig; + if (orig->f_op->remap_file_range) { + return orig->f_op->remap_file_range(orig, pos_in, file_out, pos_out, len, remap_flags); + } + return -EINVAL; +} + +static int ksu_wrapper_fadvise(struct file *fp, loff_t off1, loff_t off2, int flags) { + struct ksu_file_wrapper* data = fp->private_data; + struct file* orig = data->orig; + if (orig->f_op->fadvise) { + return orig->f_op->fadvise(orig, off1, off2, flags); + } + return -EINVAL; +} + +static int ksu_wrapper_release(struct inode *inode, struct file *filp) { + ksu_delete_file_wrapper(filp->private_data); + return 0; +} + +struct ksu_file_wrapper* ksu_create_file_wrapper(struct file* fp) { + struct ksu_file_wrapper* p = kcalloc(sizeof(struct ksu_file_wrapper), 1, GFP_KERNEL); + if (!p) { + return NULL; + } + + get_file(fp); + + p->orig = fp; + p->ops.owner = THIS_MODULE; + p->ops.llseek = fp->f_op->llseek ? ksu_wrapper_llseek : NULL; + p->ops.read = fp->f_op->read ? ksu_wrapper_read : NULL; + p->ops.write = fp->f_op->write ? ksu_wrapper_write : NULL; + p->ops.read_iter = fp->f_op->read_iter ? ksu_wrapper_read_iter : NULL; + p->ops.write_iter = fp->f_op->write_iter ? ksu_wrapper_write_iter : NULL; + p->ops.iopoll = fp->f_op->iopoll ? ksu_wrapper_iopoll : NULL; +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + p->ops.iterate = fp->f_op->iterate ? ksu_wrapper_iterate : NULL; +#endif + p->ops.iterate_shared = fp->f_op->iterate_shared ? ksu_wrapper_iterate_shared : NULL; + p->ops.poll = fp->f_op->poll ? ksu_wrapper_poll : NULL; + p->ops.unlocked_ioctl = fp->f_op->unlocked_ioctl ? ksu_wrapper_unlocked_ioctl : NULL; + p->ops.compat_ioctl = fp->f_op->compat_ioctl ? ksu_wrapper_compat_ioctl : NULL; + p->ops.mmap = fp->f_op->mmap ? ksu_wrapper_mmap : NULL; +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 12, 0) + p->ops.fop_flags = fp->f_op->fop_flags; +#else + p->ops.mmap_supported_flags = fp->f_op->mmap_supported_flags; +#endif + p->ops.open = fp->f_op->open ? ksu_wrapper_open : NULL; + p->ops.flush = fp->f_op->flush ? ksu_wrapper_flush : NULL; + p->ops.release = ksu_wrapper_release; + p->ops.fsync = fp->f_op->fsync ? ksu_wrapper_fsync : NULL; + p->ops.fasync = fp->f_op->fasync ? ksu_wrapper_fasync : NULL; + p->ops.lock = fp->f_op->lock ? ksu_wrapper_lock : NULL; +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + p->ops.sendpage = fp->f_op->sendpage ? ksu_wrapper_sendpage : NULL; +#endif + p->ops.get_unmapped_area = fp->f_op->get_unmapped_area ? ksu_wrapper_get_unmapped_area : NULL; + p->ops.check_flags = fp->f_op->check_flags; + p->ops.flock = fp->f_op->flock ? ksu_wrapper_flock : NULL; + p->ops.splice_write = fp->f_op->splice_write ? ksu_wrapper_splice_write : NULL; + p->ops.splice_read = fp->f_op->splice_read ? ksu_wrapper_splice_read : NULL; + p->ops.setlease = fp->f_op->setlease ? ksu_wrapper_setlease : NULL; + p->ops.fallocate = fp->f_op->fallocate ? ksu_wrapper_fallocate : NULL; + p->ops.show_fdinfo = fp->f_op->show_fdinfo ? ksu_wrapper_show_fdinfo : NULL; + p->ops.copy_file_range = fp->f_op->copy_file_range ? ksu_wrapper_copy_file_range : NULL; + p->ops.remap_file_range = fp->f_op->remap_file_range ? ksu_wrapper_remap_file_range : NULL; + p->ops.fadvise = fp->f_op->fadvise ? ksu_wrapper_fadvise : NULL; + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 6, 0) + p->ops.splice_eof = fp->f_op->splice_eof ? ksu_wrapper_splice_eof : NULL; +#endif + + return p; +} + +void ksu_delete_file_wrapper(struct ksu_file_wrapper* data) { + fput((struct file*) data->orig); + kfree(data); +} \ No newline at end of file diff --git a/kernel/file_wrapper.h b/kernel/file_wrapper.h new file mode 100644 index 0000000..421e20e --- /dev/null +++ b/kernel/file_wrapper.h @@ -0,0 +1,14 @@ +#ifndef KSU_FILE_WRAPPER_H +#define KSU_FILE_WRAPPER_H + +#include +#include + +struct ksu_file_wrapper { + struct file* orig; + struct file_operations ops; +}; + +struct ksu_file_wrapper* ksu_create_file_wrapper(struct file* fp); +void ksu_delete_file_wrapper(struct ksu_file_wrapper* data); +#endif // KSU_FILE_WRAPPER_H \ No newline at end of file diff --git a/kernel/kernel_compat.h b/kernel/kernel_compat.h new file mode 100644 index 0000000..14e1cb2 --- /dev/null +++ b/kernel/kernel_compat.h @@ -0,0 +1,24 @@ +#ifndef __KSU_H_KERNEL_COMPAT +#define __KSU_H_KERNEL_COMPAT + +#include +#include + +/* + * ksu_copy_from_user_retry + * try nofault copy first, if it fails, try with plain + * paramters are the same as copy_from_user + * 0 = success + */ +static long ksu_copy_from_user_retry(void *to, + const void __user *from, unsigned long count) +{ + long ret = copy_from_user_nofault(to, from, count); + if (likely(!ret)) + return ret; + + // we faulted! fallback to slow path + return copy_from_user(to, from, count); +} + +#endif diff --git a/kernel/kernel_umount.c b/kernel/kernel_umount.c new file mode 100644 index 0000000..4086057 --- /dev/null +++ b/kernel/kernel_umount.c @@ -0,0 +1,175 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kernel_umount.h" +#include "klog.h" // IWYU pragma: keep +#include "allowlist.h" +#include "selinux/selinux.h" +#include "feature.h" +#include "ksud.h" + +#include "umount_manager.h" +#include "sulog.h" + +static bool ksu_kernel_umount_enabled = true; + +static int kernel_umount_feature_get(u64 *value) +{ + *value = ksu_kernel_umount_enabled ? 1 : 0; + return 0; +} + +static int kernel_umount_feature_set(u64 value) +{ + bool enable = value != 0; + ksu_kernel_umount_enabled = enable; + pr_info("kernel_umount: set to %d\n", enable); + return 0; +} + +static const struct ksu_feature_handler kernel_umount_handler = { + .feature_id = KSU_FEATURE_KERNEL_UMOUNT, + .name = "kernel_umount", + .get_handler = kernel_umount_feature_get, + .set_handler = kernel_umount_feature_set, +}; + +extern int path_umount(struct path *path, int flags); + +static void ksu_umount_mnt(struct path *path, int flags) +{ + int err = path_umount(path, flags); + if (err) { + pr_info("umount %s failed: %d\n", path->dentry->d_iname, err); + } +} + +void try_umount(const char *mnt, int flags) +{ + struct path path; + int err = kern_path(mnt, 0, &path); + if (err) { + return; + } + + if (path.dentry != path.mnt->mnt_root) { + // it is not root mountpoint, maybe umounted by others already. + path_put(&path); + return; + } + + ksu_umount_mnt(&path, flags); +} + +struct umount_tw { + struct callback_head cb; + const struct cred *old_cred; +}; + +static void umount_tw_func(struct callback_head *cb) +{ + struct umount_tw *tw = container_of(cb, struct umount_tw, cb); + const struct cred *saved = NULL; + if (tw->old_cred) { + saved = override_creds(tw->old_cred); + } + + struct mount_entry *entry; + down_read(&mount_list_lock); + list_for_each_entry(entry, &mount_list, list) { + pr_info("%s: unmounting: %s flags 0x%x\n", __func__, entry->umountable, entry->flags); + try_umount(entry->umountable, entry->flags); + } + up_read(&mount_list_lock); + + ksu_umount_manager_execute_all(tw->old_cred); + + if (saved) + revert_creds(saved); + + if (tw->old_cred) + put_cred(tw->old_cred); + + kfree(tw); +} + +int ksu_handle_umount(uid_t old_uid, uid_t new_uid) +{ + struct umount_tw *tw; + + // this hook is used for umounting overlayfs for some uid, if there isn't any module mounted, just ignore it! + if (!ksu_module_mounted) { + return 0; + } + + if (!ksu_kernel_umount_enabled) { + return 0; + } + + // FIXME: isolated process which directly forks from zygote is not handled + if (!is_appuid(new_uid)) { + return 0; + } + + if (!ksu_uid_should_umount(new_uid)) { + return 0; + } + + // check old process's selinux context, if it is not zygote, ignore it! + // because some su apps may setuid to untrusted_app but they are in global mount namespace + // when we umount for such process, that is a disaster! + bool is_zygote_child = is_zygote(get_current_cred()); + if (!is_zygote_child) { + pr_info("handle umount ignore non zygote child: %d\n", current->pid); + return 0; + } +#if __SULOG_GATE + ksu_sulog_report_syscall(new_uid, NULL, "setuid", NULL); +#endif + // umount the target mnt + pr_info("handle umount for uid: %d, pid: %d\n", new_uid, current->pid); + + tw = kzalloc(sizeof(*tw), GFP_ATOMIC); + if (!tw) + return 0; + + tw->old_cred = get_current_cred(); + tw->cb.func = umount_tw_func; + + int err = task_work_add(current, &tw->cb, TWA_RESUME); + if (err) { + if (tw->old_cred) { + put_cred(tw->old_cred); + } + kfree(tw); + pr_warn("unmount add task_work failed\n"); + } + + return 0; +} + +void ksu_kernel_umount_init(void) +{ + int rc = 0; + rc = ksu_umount_manager_init(); + if (rc) { + pr_err("Failed to initialize umount manager: %d\n", rc); + } + if (ksu_register_feature_handler(&kernel_umount_handler)) { + pr_err("Failed to register kernel_umount feature handler\n"); + } +} + +void ksu_kernel_umount_exit(void) +{ + ksu_unregister_feature_handler(KSU_FEATURE_KERNEL_UMOUNT); +} \ No newline at end of file diff --git a/kernel/kernel_umount.h b/kernel/kernel_umount.h new file mode 100644 index 0000000..65da620 --- /dev/null +++ b/kernel/kernel_umount.h @@ -0,0 +1,25 @@ +#ifndef __KSU_H_KERNEL_UMOUNT +#define __KSU_H_KERNEL_UMOUNT + +#include +#include +#include + +void ksu_kernel_umount_init(void); +void ksu_kernel_umount_exit(void); + +void try_umount(const char *mnt, int flags); + +// Handler function to be called from setresuid hook +int ksu_handle_umount(uid_t old_uid, uid_t new_uid); + +// for the umount list +struct mount_entry { + char *umountable; + unsigned int flags; + struct list_head list; +}; +extern struct list_head mount_list; +extern struct rw_semaphore mount_list_lock; + +#endif diff --git a/kernel/klog.h b/kernel/klog.h new file mode 100644 index 0000000..a934027 --- /dev/null +++ b/kernel/klog.h @@ -0,0 +1,11 @@ +#ifndef __KSU_H_KLOG +#define __KSU_H_KLOG + +#include + +#ifdef pr_fmt +#undef pr_fmt +#define pr_fmt(fmt) "KernelSU: " fmt +#endif + +#endif diff --git a/kernel/kpm/Makefile b/kernel/kpm/Makefile new file mode 100644 index 0000000..58126fb --- /dev/null +++ b/kernel/kpm/Makefile @@ -0,0 +1,6 @@ +obj-y += kpm.o +obj-y += compact.o +obj-y += super_access.o + +ccflags-y += -Wno-implicit-function-declaration -Wno-strict-prototypes -Wno-int-conversion -Wno-gcc-compat +ccflags-y += -Wno-declaration-after-statement -Wno-unused-function diff --git a/kernel/kpm/compact.c b/kernel/kpm/compact.c new file mode 100644 index 0000000..5791db4 --- /dev/null +++ b/kernel/kpm/compact.c @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "kpm.h" +#include "compact.h" +#include "../allowlist.h" +#include "../manager.h" + +static int sukisu_is_su_allow_uid(uid_t uid) +{ + return ksu_is_allow_uid_for_current(uid) ? 1 : 0; +} + +static int sukisu_get_ap_mod_exclude(uid_t uid) +{ + return 0; /* Not supported */ +} + +static int sukisu_is_uid_should_umount(uid_t uid) +{ + return ksu_uid_should_umount(uid) ? 1 : 0; +} + +static int sukisu_is_current_uid_manager(void) +{ + return is_manager(); +} + +static uid_t sukisu_get_manager_uid(void) +{ + return ksu_manager_uid; +} + +static void sukisu_set_manager_uid(uid_t uid, int force) +{ + if (force || ksu_manager_uid == -1) + ksu_manager_uid = uid; +} + +struct CompactAddressSymbol { + const char *symbol_name; + void *addr; +}; + +unsigned long sukisu_compact_find_symbol(const char *name); + +static struct CompactAddressSymbol address_symbol[] = { + { "kallsyms_lookup_name", &kallsyms_lookup_name }, + { "compact_find_symbol", &sukisu_compact_find_symbol }, + { "is_run_in_sukisu_ultra", (void *)1 }, + { "is_su_allow_uid", &sukisu_is_su_allow_uid }, + { "get_ap_mod_exclude", &sukisu_get_ap_mod_exclude }, + { "is_uid_should_umount", &sukisu_is_uid_should_umount }, + { "is_current_uid_manager", &sukisu_is_current_uid_manager }, + { "get_manager_uid", &sukisu_get_manager_uid }, + { "sukisu_set_manager_uid", &sukisu_set_manager_uid } +}; + +unsigned long sukisu_compact_find_symbol(const char* name) +{ + int i; + unsigned long addr; + + for (i = 0; i < (sizeof(address_symbol) / sizeof(struct CompactAddressSymbol)); i++) { + struct CompactAddressSymbol *symbol = &address_symbol[i]; + + if (strcmp(name, symbol->symbol_name) == 0) + return (unsigned long)symbol->addr; + } + + addr = kallsyms_lookup_name(name); + if (addr) + return addr; + + return 0; +} +EXPORT_SYMBOL(sukisu_compact_find_symbol); diff --git a/kernel/kpm/compact.h b/kernel/kpm/compact.h new file mode 100644 index 0000000..c9f69dc --- /dev/null +++ b/kernel/kpm/compact.h @@ -0,0 +1,6 @@ +#ifndef __SUKISU_KPM_COMPACT_H +#define __SUKISU_KPM_COMPACT_H + +extern unsigned long sukisu_compact_find_symbol(const char *name); + +#endif diff --git a/kernel/kpm/kpm.c b/kernel/kpm/kpm.c new file mode 100644 index 0000000..e31384b --- /dev/null +++ b/kernel/kpm/kpm.c @@ -0,0 +1,282 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +/* + * Copyright (C) 2025 Liankong (xhsw.new@outlook.com). All Rights Reserved. + * 本代码由GPL-2授权 + * + * 适配KernelSU的KPM 内核模块加载器兼容实现 + * + * 集成了 ELF 解析、内存布局、符号处理、重定位(支持 ARM64 重定位类型) + * 并参照KernelPatch的标准KPM格式实现加载和控制 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5,0,0) && defined(CONFIG_MODULES) +#include +#endif +#include "kpm.h" +#include "compact.h" + +#define KPM_NAME_LEN 32 +#define KPM_ARGS_LEN 1024 + +#ifndef NO_OPTIMIZE +#if defined(__GNUC__) && !defined(__clang__) + #define NO_OPTIMIZE __attribute__((optimize("O0"))) +#elif defined(__clang__) + #define NO_OPTIMIZE __attribute__((optnone)) +#else + #define NO_OPTIMIZE +#endif +#endif + +noinline NO_OPTIMIZE void sukisu_kpm_load_module_path(const char *path, + const char *args, void *ptr, int *result) +{ + pr_info("kpm: Stub function called (sukisu_kpm_load_module_path). " + "path=%s args=%s ptr=%p\n", path, args, ptr); + + __asm__ volatile("nop"); +} +EXPORT_SYMBOL(sukisu_kpm_load_module_path); + +noinline NO_OPTIMIZE void sukisu_kpm_unload_module(const char *name, + void *ptr, int *result) +{ + pr_info("kpm: Stub function called (sukisu_kpm_unload_module). " + "name=%s ptr=%p\n", name, ptr); + + __asm__ volatile("nop"); +} +EXPORT_SYMBOL(sukisu_kpm_unload_module); + +noinline NO_OPTIMIZE void sukisu_kpm_num(int *result) +{ + pr_info("kpm: Stub function called (sukisu_kpm_num).\n"); + + __asm__ volatile("nop"); +} +EXPORT_SYMBOL(sukisu_kpm_num); + +noinline NO_OPTIMIZE void sukisu_kpm_info(const char *name, char *buf, int bufferSize, + int *size) +{ + pr_info("kpm: Stub function called (sukisu_kpm_info). " + "name=%s buffer=%p\n", name, buf); + + __asm__ volatile("nop"); +} +EXPORT_SYMBOL(sukisu_kpm_info); + +noinline NO_OPTIMIZE void sukisu_kpm_list(void *out, int bufferSize, + int *result) +{ + pr_info("kpm: Stub function called (sukisu_kpm_list). " + "buffer=%p size=%d\n", out, bufferSize); +} +EXPORT_SYMBOL(sukisu_kpm_list); + +noinline NO_OPTIMIZE void sukisu_kpm_control(const char *name, const char *args, long arg_len, + int *result) +{ + pr_info("kpm: Stub function called (sukisu_kpm_control). " + "name=%p args=%p arg_len=%ld\n", name, args, arg_len); + + __asm__ volatile("nop"); +} +EXPORT_SYMBOL(sukisu_kpm_control); + +noinline NO_OPTIMIZE void sukisu_kpm_version(char *buf, int bufferSize) +{ + pr_info("kpm: Stub function called (sukisu_kpm_version). " + "buffer=%p\n", buf); +} +EXPORT_SYMBOL(sukisu_kpm_version); + +noinline int sukisu_handle_kpm(unsigned long control_code, unsigned long arg1, unsigned long arg2, + unsigned long result_code) +{ + int res = -1; + if (control_code == SUKISU_KPM_LOAD) { + char kernel_load_path[256]; + char kernel_args_buffer[256]; + + if (arg1 == 0) { + res = -EINVAL; + goto exit; + } + + if (!access_ok(arg1, 255)) { + goto invalid_arg; + } + + strncpy_from_user((char *)&kernel_load_path, (const char *)arg1, 255); + + if (arg2 != 0) { + if (!access_ok(arg2, 255)) { + goto invalid_arg; + } + + strncpy_from_user((char *)&kernel_args_buffer, (const char *)arg2, 255); + } + + sukisu_kpm_load_module_path((const char *)&kernel_load_path, + (const char *)&kernel_args_buffer, NULL, &res); + } else if (control_code == SUKISU_KPM_UNLOAD) { + char kernel_name_buffer[256]; + + if (arg1 == 0) { + res = -EINVAL; + goto exit; + } + + if (!access_ok(arg1, sizeof(kernel_name_buffer))) { + goto invalid_arg; + } + + strncpy_from_user((char *)&kernel_name_buffer, (const char *)arg1, sizeof(kernel_name_buffer)); + + sukisu_kpm_unload_module((const char *)&kernel_name_buffer, NULL, &res); + } else if (control_code == SUKISU_KPM_NUM) { + sukisu_kpm_num(&res); + } else if (control_code == SUKISU_KPM_INFO) { + char kernel_name_buffer[256]; + char buf[256]; + int size; + + if (arg1 == 0 || arg2 == 0) { + res = -EINVAL; + goto exit; + } + + if (!access_ok(arg1, sizeof(kernel_name_buffer))) { + goto invalid_arg; + } + + strncpy_from_user((char *)&kernel_name_buffer, (const char __user *)arg1, sizeof(kernel_name_buffer)); + + sukisu_kpm_info((const char *)&kernel_name_buffer, (char *)&buf, sizeof(buf), &size); + + if (!access_ok(arg2, size)) { + goto invalid_arg; + } + + res = copy_to_user(arg2, &buf, size); + + } else if (control_code == SUKISU_KPM_LIST) { + char buf[1024]; + int len = (int) arg2; + + if (len <= 0) { + res = -EINVAL; + goto exit; + } + + if (!access_ok(arg2, len)) { + goto invalid_arg; + } + + sukisu_kpm_list((char *)&buf, sizeof(buf), &res); + + if (res > len) { + res = -ENOBUFS; + goto exit; + } + + if (copy_to_user(arg1, &buf, len) != 0) + pr_info("kpm: Copy to user failed."); + + } else if (control_code == SUKISU_KPM_CONTROL) { + char kpm_name[KPM_NAME_LEN] = { 0 }; + char kpm_args[KPM_ARGS_LEN] = { 0 }; + + if (!access_ok(arg1, sizeof(kpm_name))) { + goto invalid_arg; + } + + if (!access_ok(arg2, sizeof(kpm_args))) { + goto invalid_arg; + } + + long name_len = strncpy_from_user((char *)&kpm_name, (const char __user *)arg1, sizeof(kpm_name)); + if (name_len <= 0) { + res = -EINVAL; + goto exit; + } + + long arg_len = strncpy_from_user((char *)&kpm_args, (const char __user *)arg2, sizeof(kpm_args)); + + sukisu_kpm_control((const char *)&kpm_name, (const char *)&kpm_args, arg_len, &res); + + } else if (control_code == SUKISU_KPM_VERSION) { + char buffer[256] = {0}; + + sukisu_kpm_version((char*) &buffer, sizeof(buffer)); + + unsigned int outlen = (unsigned int) arg2; + int len = strlen(buffer); + if (len >= outlen) len = outlen - 1; + + res = copy_to_user(arg1, &buffer, len + 1); + } + +exit: + if (copy_to_user(result_code, &res, sizeof(res)) != 0) + pr_info("kpm: Copy to user failed."); + + return 0; +invalid_arg: + pr_err("kpm: invalid pointer detected! arg1: %px arg2: %px\n", (void *)arg1, (void *)arg2); + res = -EFAULT; + goto exit; +} +EXPORT_SYMBOL(sukisu_handle_kpm); + +int sukisu_is_kpm_control_code(unsigned long control_code) { + return (control_code >= CMD_KPM_CONTROL && + control_code <= CMD_KPM_CONTROL_MAX) ? 1 : 0; +} + +int do_kpm(void __user *arg) +{ + struct ksu_kpm_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("kpm: copy_from_user failed\n"); + return -EFAULT; + } + + if (!access_ok(cmd.control_code, sizeof(int))) { + pr_err("kpm: invalid control_code pointer %px\n", (void *)cmd.control_code); + return -EFAULT; + } + + if (!access_ok(cmd.result_code, sizeof(int))) { + pr_err("kpm: invalid result_code pointer %px\n", (void *)cmd.result_code); + return -EFAULT; + } + + return sukisu_handle_kpm(cmd.control_code, cmd.arg1, cmd.arg2, cmd.result_code); +} + diff --git a/kernel/kpm/kpm.h b/kernel/kpm/kpm.h new file mode 100644 index 0000000..4fdcc20 --- /dev/null +++ b/kernel/kpm/kpm.h @@ -0,0 +1,70 @@ +#ifndef __SUKISU_KPM_H +#define __SUKISU_KPM_H + +#include +#include + +struct ksu_kpm_cmd { + __aligned_u64 __user control_code; + __aligned_u64 __user arg1; + __aligned_u64 __user arg2; + __aligned_u64 __user result_code; +}; + +int sukisu_handle_kpm(unsigned long control_code, unsigned long arg3, unsigned long arg4, unsigned long result_code); +int sukisu_is_kpm_control_code(unsigned long control_code); +int do_kpm(void __user *arg); + +#define KSU_IOCTL_KPM _IOC(_IOC_READ|_IOC_WRITE, 'K', 200, 0) + +/* KPM Control Code */ +#define CMD_KPM_CONTROL 1 +#define CMD_KPM_CONTROL_MAX 10 + +/* Control Code */ +/* + * prctl(xxx, 1, "PATH", "ARGS") + * success return 0, error return -N + */ +#define SUKISU_KPM_LOAD 1 + +/* + * prctl(xxx, 2, "NAME") + * success return 0, error return -N + */ +#define SUKISU_KPM_UNLOAD 2 + +/* + * num = prctl(xxx, 3) + * error return -N + * success return +num or 0 + */ +#define SUKISU_KPM_NUM 3 + +/* + * prctl(xxx, 4, Buffer, BufferSize) + * success return +out, error return -N + */ +#define SUKISU_KPM_LIST 4 + +/* + * prctl(xxx, 5, "NAME", Buffer[256]) + * success return +out, error return -N + */ +#define SUKISU_KPM_INFO 5 + +/* + * prctl(xxx, 6, "NAME", "ARGS") + * success return KPM's result value + * error return -N + */ +#define SUKISU_KPM_CONTROL 6 + +/* + * prctl(xxx, 7, buffer, bufferSize) + * success return KPM's result value + * error return -N + */ +#define SUKISU_KPM_VERSION 7 + +#endif diff --git a/kernel/kpm/super_access.c b/kernel/kpm/super_access.c new file mode 100644 index 0000000..9bb7779 --- /dev/null +++ b/kernel/kpm/super_access.c @@ -0,0 +1,278 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include <../fs/mount.h> +#include "kpm.h" +#include "compact.h" + +struct DynamicStructMember { + const char *name; + size_t size; + size_t offset; +}; + +struct DynamicStructInfo { + const char *name; + size_t count; + size_t total_size; + struct DynamicStructMember *members; +}; + +#define DYNAMIC_STRUCT_BEGIN(struct_name) \ + static struct DynamicStructMember struct_name##_members[] = { + +#define DEFINE_MEMBER(struct_name, member) \ + { \ + .name = #member, \ + .size = sizeof(((struct struct_name *)0)->member), \ + .offset = offsetof(struct struct_name, member) \ + }, + +#define DYNAMIC_STRUCT_END(struct_name) \ + }; \ + static struct DynamicStructInfo struct_name##_info = { \ + .name = #struct_name, \ + .count = sizeof(struct_name##_members) / sizeof(struct DynamicStructMember), \ + .total_size = sizeof(struct struct_name), \ + .members = struct_name##_members \ + }; + +DYNAMIC_STRUCT_BEGIN(mount) + DEFINE_MEMBER(mount, mnt_parent) + DEFINE_MEMBER(mount, mnt) + DEFINE_MEMBER(mount, mnt_id) + DEFINE_MEMBER(mount, mnt_group_id) + DEFINE_MEMBER(mount, mnt_expiry_mark) + DEFINE_MEMBER(mount, mnt_master) + DEFINE_MEMBER(mount, mnt_devname) +DYNAMIC_STRUCT_END(mount) + +DYNAMIC_STRUCT_BEGIN(vfsmount) + DEFINE_MEMBER(vfsmount, mnt_root) + DEFINE_MEMBER(vfsmount, mnt_sb) + DEFINE_MEMBER(vfsmount, mnt_flags) +DYNAMIC_STRUCT_END(vfsmount) + +DYNAMIC_STRUCT_BEGIN(mnt_namespace) + DEFINE_MEMBER(mnt_namespace, ns) + DEFINE_MEMBER(mnt_namespace, root) + DEFINE_MEMBER(mnt_namespace, seq) + DEFINE_MEMBER(mnt_namespace, mounts) +#if LINUX_VERSION_CODE < KERNEL_VERSION(5, 15, 0) + DEFINE_MEMBER(mnt_namespace, count) +#endif +DYNAMIC_STRUCT_END(mnt_namespace) + +#ifdef CONFIG_KPROBES +DYNAMIC_STRUCT_BEGIN(kprobe) + DEFINE_MEMBER(kprobe, addr) + DEFINE_MEMBER(kprobe, symbol_name) + DEFINE_MEMBER(kprobe, offset) + DEFINE_MEMBER(kprobe, pre_handler) + DEFINE_MEMBER(kprobe, post_handler) +#if LINUX_VERSION_CODE < KERNEL_VERSION(5, 15, 0) + DEFINE_MEMBER(kprobe, fault_handler) +#endif + DEFINE_MEMBER(kprobe, flags) +DYNAMIC_STRUCT_END(kprobe) +#endif + +DYNAMIC_STRUCT_BEGIN(vm_area_struct) + DEFINE_MEMBER(vm_area_struct,vm_start) + DEFINE_MEMBER(vm_area_struct,vm_end) + DEFINE_MEMBER(vm_area_struct,vm_flags) + DEFINE_MEMBER(vm_area_struct,anon_vma) + DEFINE_MEMBER(vm_area_struct,vm_pgoff) + DEFINE_MEMBER(vm_area_struct,vm_file) + DEFINE_MEMBER(vm_area_struct,vm_private_data) +#ifdef CONFIG_ANON_VMA_NAME + DEFINE_MEMBER(vm_area_struct, anon_name) +#endif + DEFINE_MEMBER(vm_area_struct, vm_ops) +DYNAMIC_STRUCT_END(vm_area_struct) + +DYNAMIC_STRUCT_BEGIN(vm_operations_struct) + DEFINE_MEMBER(vm_operations_struct, open) + DEFINE_MEMBER(vm_operations_struct, close) + DEFINE_MEMBER(vm_operations_struct, name) + DEFINE_MEMBER(vm_operations_struct, access) +DYNAMIC_STRUCT_END(vm_operations_struct) + +DYNAMIC_STRUCT_BEGIN(netlink_kernel_cfg) + DEFINE_MEMBER(netlink_kernel_cfg, groups) + DEFINE_MEMBER(netlink_kernel_cfg, flags) + DEFINE_MEMBER(netlink_kernel_cfg, input) + DEFINE_MEMBER(netlink_kernel_cfg, cb_mutex) + DEFINE_MEMBER(netlink_kernel_cfg, bind) + DEFINE_MEMBER(netlink_kernel_cfg, unbind) +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 1, 0) + DEFINE_MEMBER(netlink_kernel_cfg, compare) +#endif +DYNAMIC_STRUCT_END(netlink_kernel_cfg) + +DYNAMIC_STRUCT_BEGIN(task_struct) + DEFINE_MEMBER(task_struct, pid) + DEFINE_MEMBER(task_struct, tgid) + DEFINE_MEMBER(task_struct, cred) + DEFINE_MEMBER(task_struct, real_cred) + DEFINE_MEMBER(task_struct, comm) + DEFINE_MEMBER(task_struct, parent) + DEFINE_MEMBER(task_struct, group_leader) + DEFINE_MEMBER(task_struct, mm) + DEFINE_MEMBER(task_struct, active_mm) +#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 19, 0) + DEFINE_MEMBER(task_struct, pids[PIDTYPE_PID].pid) +#else + DEFINE_MEMBER(task_struct, thread_pid) +#endif + DEFINE_MEMBER(task_struct, files) + DEFINE_MEMBER(task_struct, seccomp) +#ifdef CONFIG_THREAD_INFO_IN_TASK + DEFINE_MEMBER(task_struct, thread_info) +#endif +#ifdef CONFIG_CGROUPS + DEFINE_MEMBER(task_struct, cgroups) +#endif +#ifdef CONFIG_SECURITY + DEFINE_MEMBER(task_struct, security) +#endif + DEFINE_MEMBER(task_struct, thread) +DYNAMIC_STRUCT_END(task_struct) + +#define STRUCT_INFO(name) &(name##_info) + +static struct DynamicStructInfo *dynamic_struct_infos[] = { + STRUCT_INFO(mount), + STRUCT_INFO(vfsmount), + STRUCT_INFO(mnt_namespace), +#ifdef CONFIG_KPROBES + STRUCT_INFO(kprobe), +#endif + STRUCT_INFO(vm_area_struct), + STRUCT_INFO(vm_operations_struct), + STRUCT_INFO(netlink_kernel_cfg), + STRUCT_INFO(task_struct) +}; + +/* + * return 0 if successful + * return -1 if struct not defined + */ +int sukisu_super_find_struct(const char *struct_name, size_t *out_size, int *out_members) +{ + for (size_t i = 0; i < (sizeof(dynamic_struct_infos) / sizeof(dynamic_struct_infos[0])); i++) { + struct DynamicStructInfo *info = dynamic_struct_infos[i]; + + if (strcmp(struct_name, info->name) == 0) { + if (out_size) + *out_size = info->total_size; + + if (out_members) + *out_members = info->count; + + return 0; + } + } + + return -1; +} +EXPORT_SYMBOL(sukisu_super_find_struct); + +/* + * Dynamic access struct + * return 0 if successful + * return -1 if struct not defined + * return -2 if member not defined + */ +int sukisu_super_access(const char *struct_name, const char *member_name, size_t *out_offset, + size_t *out_size) +{ + for (size_t i = 0; i < (sizeof(dynamic_struct_infos) / sizeof(dynamic_struct_infos[0])); i++) { + struct DynamicStructInfo *info = dynamic_struct_infos[i]; + + if (strcmp(struct_name, info->name) == 0) { + for (size_t i1 = 0; i1 < info->count; i1++) { + if (strcmp(info->members[i1].name, member_name) == 0) { + if (out_offset) + *out_offset = info->members[i].offset; + + if (out_size) + *out_size = info->members[i].size; + + return 0; + } + } + + return -2; + } + } + + return -1; +} +EXPORT_SYMBOL(sukisu_super_access); + +#define DYNAMIC_CONTAINER_OF(offset, member_ptr) ({ \ + (offset != (size_t)-1) ? (void*)((char*)(member_ptr) - offset) : NULL; \ +}) + +/* + * Dynamic container_of + * return 0 if success + * return -1 if current struct not defined + * return -2 if target member not defined + */ +int sukisu_super_container_of(const char *struct_name, const char *member_name, void *ptr, + void **out_ptr) +{ + if (ptr == NULL) + return -3; + + for (size_t i = 0; i < (sizeof(dynamic_struct_infos) / sizeof(dynamic_struct_infos[0])); i++) { + struct DynamicStructInfo *info = dynamic_struct_infos[i]; + + if (strcmp(struct_name, info->name) == 0) { + for (size_t i1 = 0; i1 < info->count; i1++) { + if (strcmp(info->members[i1].name, member_name) == 0) { + *out_ptr = (void *)DYNAMIC_CONTAINER_OF(info->members[i1].offset, ptr); + + return 0; + } + } + + return -2; + } + } + + return -1; +} +EXPORT_SYMBOL(sukisu_super_container_of); diff --git a/kernel/kpm/super_access.h b/kernel/kpm/super_access.h new file mode 100644 index 0000000..4f02109 --- /dev/null +++ b/kernel/kpm/super_access.h @@ -0,0 +1,15 @@ +#ifndef __SUKISU_SUPER_ACCESS_H +#define __SUKISU_SUPER_ACCESS_H + +#include +#include +#include "kpm.h" +#include "compact.h" + +extern int sukisu_super_find_struct(const char *struct_name, size_t *out_size, int *out_members); +extern int sukisu_super_access(const char *struct_name, const char *member_name, size_t *out_offset, + size_t *out_size); +extern int sukisu_super_container_of(const char *struct_name, const char *member_name, void *ptr, + void **out_ptr); + +#endif \ No newline at end of file diff --git a/kernel/ksu.c b/kernel/ksu.c new file mode 100644 index 0000000..850050c --- /dev/null +++ b/kernel/ksu.c @@ -0,0 +1,116 @@ +#include +#include +#include +#include +#include +#include + +#include "allowlist.h" +#include "feature.h" +#include "klog.h" // IWYU pragma: keep +#include "throne_tracker.h" +#include "syscall_hook_manager.h" +#include "ksud.h" +#include "supercalls.h" + +#include "sulog.h" +#include "throne_comm.h" +#include "dynamic_manager.h" + +static struct workqueue_struct *ksu_workqueue; + +bool ksu_queue_work(struct work_struct *work) +{ + return queue_work(ksu_workqueue, work); +} + +void sukisu_custom_config_init(void) +{ +} + +void sukisu_custom_config_exit(void) +{ + ksu_uid_exit(); + ksu_throne_comm_exit(); + ksu_dynamic_manager_exit(); +#if __SULOG_GATE + ksu_sulog_exit(); +#endif +} + +int __init kernelsu_init(void) +{ +#ifdef CONFIG_KSU_DEBUG + pr_alert("*************************************************************"); + pr_alert("** NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE **"); + pr_alert("** **"); + pr_alert("** You are running KernelSU in DEBUG mode **"); + pr_alert("** **"); + pr_alert("** NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE **"); + pr_alert("*************************************************************"); +#endif + + ksu_feature_init(); + + ksu_supercalls_init(); + + sukisu_custom_config_init(); + + ksu_syscall_hook_manager_init(); + + ksu_workqueue = alloc_ordered_workqueue("kernelsu_work_queue", 0); + + ksu_allowlist_init(); + + ksu_throne_tracker_init(); + +#ifdef KSU_KPROBES_HOOK + ksu_ksud_init(); +#else + pr_alert("KPROBES is disabled, KernelSU may not work, please check https://kernelsu.org/guide/how-to-integrate-for-non-gki.html"); +#endif + +#ifdef MODULE +#ifndef CONFIG_KSU_DEBUG + kobject_del(&THIS_MODULE->mkobj.kobj); +#endif +#endif + return 0; +} + +extern void ksu_observer_exit(void); +void kernelsu_exit(void) +{ + ksu_allowlist_exit(); + + ksu_observer_exit(); + + ksu_throne_tracker_exit(); + + destroy_workqueue(ksu_workqueue); + +#ifdef KSU_KPROBES_HOOK + ksu_ksud_exit(); +#endif + + ksu_syscall_hook_manager_exit(); + + sukisu_custom_config_exit(); + + ksu_supercalls_exit(); + + ksu_feature_exit(); +} + +module_init(kernelsu_init); +module_exit(kernelsu_exit); + +MODULE_LICENSE("GPL"); +MODULE_AUTHOR("weishu"); +MODULE_DESCRIPTION("Android KernelSU"); + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 13, 0) +MODULE_IMPORT_NS("VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver"); +#else +MODULE_IMPORT_NS(VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver); +#endif diff --git a/kernel/ksu.h b/kernel/ksu.h new file mode 100644 index 0000000..93750af --- /dev/null +++ b/kernel/ksu.h @@ -0,0 +1,62 @@ +#ifndef __KSU_H_KSU +#define __KSU_H_KSU + +#include +#include + +#define KERNEL_SU_VERSION KSU_VERSION +#define KERNEL_SU_OPTION 0xDEADBEEF + +extern bool ksu_uid_scanner_enabled; + +#define EVENT_POST_FS_DATA 1 +#define EVENT_BOOT_COMPLETED 2 +#define EVENT_MODULE_MOUNTED 3 + +// SukiSU Ultra kernel su version full strings +#ifndef KSU_VERSION_FULL +#define KSU_VERSION_FULL "v3.x-00000000@unknown" +#endif +#define KSU_FULL_VERSION_STRING 255 + +#define DYNAMIC_MANAGER_OP_SET 0 +#define DYNAMIC_MANAGER_OP_GET 1 +#define DYNAMIC_MANAGER_OP_CLEAR 2 + +#define UID_SCANNER_OP_GET_STATUS 0 +#define UID_SCANNER_OP_TOGGLE 1 +#define UID_SCANNER_OP_CLEAR_ENV 2 + +struct dynamic_manager_user_config { + unsigned int operation; + unsigned int size; + char hash[65]; +}; + +struct manager_list_info { + int count; + struct { + uid_t uid; + int signature_index; + } managers[2]; +}; + +bool ksu_queue_work(struct work_struct *work); + +#if 0 +static inline int startswith(char *s, char *prefix) +{ + return strncmp(s, prefix, strlen(prefix)); +} + +static inline int endswith(const char *s, const char *t) +{ + size_t slen = strlen(s); + size_t tlen = strlen(t); + if (tlen > slen) + return 1; + return strcmp(s + slen - tlen, t); +} +#endif + +#endif diff --git a/kernel/ksud.c b/kernel/ksud.c new file mode 100644 index 0000000..59f9279 --- /dev/null +++ b/kernel/ksud.c @@ -0,0 +1,660 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "manager.h" +#include "allowlist.h" +#include "arch.h" +#include "klog.h" // IWYU pragma: keep +#include "ksud.h" +#include "selinux/selinux.h" +#include "throne_tracker.h" + +bool ksu_module_mounted __read_mostly = false; +bool ksu_boot_completed __read_mostly = false; + +static const char KERNEL_SU_RC[] = + "\n" + + "on post-fs-data\n" + " start logd\n" + // We should wait for the post-fs-data finish + " exec u:r:su:s0 root -- " KSUD_PATH " post-fs-data\n" + "\n" + + "on nonencrypted\n" + " exec u:r:su:s0 root -- " KSUD_PATH " services\n" + "\n" + + "on property:vold.decrypt=trigger_restart_framework\n" + " exec u:r:su:s0 root -- " KSUD_PATH " services\n" + "\n" + + "on property:sys.boot_completed=1\n" + " exec u:r:su:s0 root -- " KSUD_PATH " boot-completed\n" + "\n" + + "\n"; + +static void stop_vfs_read_hook(); +static void stop_execve_hook(); +static void stop_input_hook(); + +#ifdef KSU_KPROBES_HOOK +static struct work_struct stop_vfs_read_work; +static struct work_struct stop_execve_hook_work; +static struct work_struct stop_input_hook_work; +#else +bool ksu_vfs_read_hook __read_mostly = true; +bool ksu_execveat_hook __read_mostly = true; +bool ksu_input_hook __read_mostly = true; +#endif + +u32 ksu_file_sid; + +// Detect whether it is on or not +static bool is_boot_phase = true; + +void on_post_fs_data(void) +{ + static bool done = false; + if (done) { + pr_info("on_post_fs_data already done\n"); + return; + } + done = true; + pr_info("on_post_fs_data!\n"); + ksu_load_allow_list(); + ksu_observer_init(); + // sanity check, this may influence the performance + stop_input_hook(); + + // End of boot state + is_boot_phase = false; + + ksu_file_sid = ksu_get_ksu_file_sid(); + pr_info("ksu_file sid: %d\n", ksu_file_sid); +} + +extern void ext4_unregister_sysfs(struct super_block *sb); +int nuke_ext4_sysfs(const char* mnt) +{ +#ifdef CONFIG_EXT4_FS + struct path path; + int err = kern_path(mnt, 0, &path); + if (err) { + pr_err("nuke path err: %d\n", err); + return err; + } + + struct super_block *sb = path.dentry->d_inode->i_sb; + const char *name = sb->s_type->name; + if (strcmp(name, "ext4") != 0) { + pr_info("nuke but module aren't mounted\n"); + path_put(&path); + return -EINVAL; + } + + ext4_unregister_sysfs(sb); + path_put(&path); + + return 0; +#endif +} + +void on_module_mounted(void){ + pr_info("on_module_mounted!\n"); + ksu_module_mounted = true; +} + +void on_boot_completed(void){ + ksu_boot_completed = true; + pr_info("on_boot_completed!\n"); + track_throne(true); +} + +#define MAX_ARG_STRINGS 0x7FFFFFFF +struct user_arg_ptr { +#ifdef CONFIG_COMPAT + bool is_compat; +#endif + union { + const char __user *const __user *native; +#ifdef CONFIG_COMPAT + const compat_uptr_t __user *compat; +#endif + } ptr; +}; + +static const char __user *get_user_arg_ptr(struct user_arg_ptr argv, int nr) +{ + const char __user *native; + +#ifdef CONFIG_COMPAT + if (unlikely(argv.is_compat)) { + compat_uptr_t compat; + + if (get_user(compat, argv.ptr.compat + nr)) + return ERR_PTR(-EFAULT); + + return compat_ptr(compat); + } +#endif + + if (get_user(native, argv.ptr.native + nr)) + return ERR_PTR(-EFAULT); + + return native; +} + +/* + * count() counts the number of strings in array ARGV. + */ + +/* + * Make sure old GCC compiler can use __maybe_unused, + * Test passed in 4.4.x ~ 4.9.x when use GCC. + */ + +static int __maybe_unused count(struct user_arg_ptr argv, int max) +{ + int i = 0; + + if (argv.ptr.native != NULL) { + for (;;) { + const char __user *p = get_user_arg_ptr(argv, i); + + if (!p) + break; + + if (IS_ERR(p)) + return -EFAULT; + + if (i >= max) + return -E2BIG; + ++i; + + if (fatal_signal_pending(current)) + return -ERESTARTNOHAND; + } + } + return i; +} + +static void on_post_fs_data_cbfun(struct callback_head *cb) +{ + on_post_fs_data(); +} + +static struct callback_head on_post_fs_data_cb = { .func = + on_post_fs_data_cbfun }; + +// IMPORTANT NOTE: the call from execve_handler_pre WON'T provided correct value for envp and flags in GKI version +int ksu_handle_execveat_ksud(int *fd, struct filename **filename_ptr, + struct user_arg_ptr *argv, + struct user_arg_ptr *envp, int *flags) +{ +#ifndef KSU_KPROBES_HOOK + if (!ksu_execveat_hook) { + return 0; + } +#endif + struct filename *filename; + + static const char app_process[] = "/system/bin/app_process"; + static bool first_app_process = true; + + /* This applies to versions Android 10+ */ + static const char system_bin_init[] = "/system/bin/init"; + /* This applies to versions between Android 6 ~ 9 */ + static const char old_system_init[] = "/init"; + static bool init_second_stage_executed = false; + + if (!filename_ptr) + return 0; + + filename = *filename_ptr; + if (IS_ERR(filename)) { + return 0; + } + + if (unlikely(!memcmp(filename->name, system_bin_init, + sizeof(system_bin_init) - 1) && + argv)) { + // /system/bin/init executed + int argc = count(*argv, MAX_ARG_STRINGS); + pr_info("/system/bin/init argc: %d\n", argc); + if (argc > 1 && !init_second_stage_executed) { + const char __user *p = get_user_arg_ptr(*argv, 1); + if (p && !IS_ERR(p)) { + char first_arg[16]; + strncpy_from_user_nofault(first_arg, p, sizeof(first_arg)); + pr_info("/system/bin/init first arg: %s\n", first_arg); + if (!strcmp(first_arg, "second_stage")) { + pr_info("/system/bin/init second_stage executed\n"); + apply_kernelsu_rules(); + init_second_stage_executed = true; + } + } else { + pr_err("/system/bin/init parse args err!\n"); + } + } + } else if (unlikely(!memcmp(filename->name, old_system_init, + sizeof(old_system_init) - 1) && + argv)) { + // /init executed + int argc = count(*argv, MAX_ARG_STRINGS); + pr_info("/init argc: %d\n", argc); + if (argc > 1 && !init_second_stage_executed) { + /* This applies to versions between Android 6 ~ 7 */ + const char __user *p = get_user_arg_ptr(*argv, 1); + if (p && !IS_ERR(p)) { + char first_arg[16]; + strncpy_from_user_nofault(first_arg, p, sizeof(first_arg)); + pr_info("/init first arg: %s\n", first_arg); + if (!strcmp(first_arg, "--second-stage")) { + pr_info("/init second_stage executed\n"); + apply_kernelsu_rules(); + init_second_stage_executed = true; + } + } else { + pr_err("/init parse args err!\n"); + } + } else if (argc == 1 && !init_second_stage_executed && envp) { + /* This applies to versions between Android 8 ~ 9 */ + int envc = count(*envp, MAX_ARG_STRINGS); + if (envc > 0) { + int n; + for (n = 1; n <= envc; n++) { + const char __user *p = get_user_arg_ptr(*envp, n); + if (!p || IS_ERR(p)) { + continue; + } + char env[256]; + // Reading environment variable strings from user space + if (strncpy_from_user_nofault(env, p, sizeof(env)) < 0) + continue; + // Parsing environment variable names and values + char *env_name = env; + char *env_value = strchr(env, '='); + if (env_value == NULL) + continue; + // Replace equal sign with string terminator + *env_value = '\0'; + env_value++; + // Check if the environment variable name and value are matching + if (!strcmp(env_name, "INIT_SECOND_STAGE") && + (!strcmp(env_value, "1") || + !strcmp(env_value, "true"))) { + pr_info("/init second_stage executed\n"); + apply_kernelsu_rules(); + init_second_stage_executed = true; + } + } + } + } + } + + if (unlikely(first_app_process && !memcmp(filename->name, app_process, + sizeof(app_process) - 1))) { + first_app_process = false; + pr_info("exec app_process, /data prepared, second_stage: %d\n", + init_second_stage_executed); + struct task_struct *init_task; + rcu_read_lock(); + init_task = rcu_dereference(current->real_parent); + if (init_task) { + task_work_add(init_task, &on_post_fs_data_cb, TWA_RESUME); + } + rcu_read_unlock(); + + stop_execve_hook(); + } + + return 0; +} + +static ssize_t (*orig_read)(struct file *, char __user *, size_t, loff_t *); +static ssize_t (*orig_read_iter)(struct kiocb *, struct iov_iter *); +static struct file_operations fops_proxy; +static ssize_t read_count_append = 0; + +static ssize_t read_proxy(struct file *file, char __user *buf, size_t count, + loff_t *pos) +{ + bool first_read = file->f_pos == 0; + ssize_t ret = orig_read(file, buf, count, pos); + if (first_read) { + pr_info("read_proxy append %ld + %ld\n", ret, read_count_append); + ret += read_count_append; + } + return ret; +} + +static ssize_t read_iter_proxy(struct kiocb *iocb, struct iov_iter *to) +{ + bool first_read = iocb->ki_pos == 0; + ssize_t ret = orig_read_iter(iocb, to); + if (first_read) { + pr_info("read_iter_proxy append %ld + %ld\n", ret, read_count_append); + ret += read_count_append; + } + return ret; +} + +static int ksu_handle_vfs_read(struct file **file_ptr, char __user **buf_ptr, + size_t *count_ptr, loff_t **pos) +{ +#ifndef KSU_KPROBES_HOOK + if (!ksu_vfs_read_hook) { + return 0; + } +#endif + struct file *file; + char __user *buf; + size_t count; + + if (strcmp(current->comm, "init")) { + // we are only interest in `init` process + return 0; + } + + file = *file_ptr; + if (IS_ERR(file)) { + return 0; + } + + if (!d_is_reg(file->f_path.dentry)) { + return 0; + } + + const char *short_name = file->f_path.dentry->d_name.name; + if (strcmp(short_name, "atrace.rc")) { + // we are only interest `atrace.rc` file name file + return 0; + } + char path[256]; + char *dpath = d_path(&file->f_path, path, sizeof(path)); + + if (IS_ERR(dpath)) { + return 0; + } + + if (strcmp(dpath, "/system/etc/init/atrace.rc")) { + return 0; + } + + // we only process the first read + static bool rc_inserted = false; + if (rc_inserted) { + // we don't need this kprobe, unregister it! + stop_vfs_read_hook(); + return 0; + } + rc_inserted = true; + + // now we can sure that the init process is reading + // `/system/etc/init/atrace.rc` + buf = *buf_ptr; + count = *count_ptr; + + size_t rc_count = strlen(KERNEL_SU_RC); + + pr_info("vfs_read: %s, comm: %s, count: %zu, rc_count: %zu\n", dpath, + current->comm, count, rc_count); + + if (count < rc_count) { + pr_err("count: %zu < rc_count: %zu\n", count, rc_count); + return 0; + } + + size_t ret = copy_to_user(buf, KERNEL_SU_RC, rc_count); + if (ret) { + pr_err("copy ksud.rc failed: %zu\n", ret); + return 0; + } + + // we've succeed to insert ksud.rc, now we need to proxy the read and modify the result! + // But, we can not modify the file_operations directly, because it's in read-only memory. + // We just replace the whole file_operations with a proxy one. + memcpy(&fops_proxy, file->f_op, sizeof(struct file_operations)); + orig_read = file->f_op->read; + if (orig_read) { + fops_proxy.read = read_proxy; + } + orig_read_iter = file->f_op->read_iter; + if (orig_read_iter) { + fops_proxy.read_iter = read_iter_proxy; + } + // replace the file_operations + file->f_op = &fops_proxy; + read_count_append = rc_count; + + *buf_ptr = buf + rc_count; + *count_ptr = count - rc_count; + + return 0; +} + +int ksu_handle_sys_read(unsigned int fd, char __user **buf_ptr, + size_t *count_ptr) +{ + struct file *file = fget(fd); + if (!file) { + return 0; + } + int result = ksu_handle_vfs_read(&file, buf_ptr, count_ptr, NULL); + fput(file); + return result; +} + +static unsigned int volumedown_pressed_count = 0; + +static bool is_volumedown_enough(unsigned int count) +{ + return count >= 3; +} + +int ksu_handle_input_handle_event(unsigned int *type, unsigned int *code, + int *value) +{ +#ifndef KSU_KPROBES_HOOK + if (!ksu_input_hook) { + return 0; + } +#endif + if (*type == EV_KEY && *code == KEY_VOLUMEDOWN) { + int val = *value; + pr_info("KEY_VOLUMEDOWN val: %d\n", val); + if (val && is_boot_phase) { + // key pressed, count it + volumedown_pressed_count += 1; + if (is_volumedown_enough(volumedown_pressed_count)) { + stop_input_hook(); + } + } + } + + return 0; +} + +bool ksu_is_safe_mode() +{ + static bool safe_mode = false; + if (safe_mode) { + // don't need to check again, userspace may call multiple times + return true; + } + + // stop hook first! + stop_input_hook(); + + pr_info("volumedown_pressed_count: %d\n", volumedown_pressed_count); + if (is_volumedown_enough(volumedown_pressed_count)) { + // pressed over 3 times + pr_info("KEY_VOLUMEDOWN pressed max times, safe mode detected!\n"); + safe_mode = true; + return true; + } + + return false; +} + +#ifdef KSU_KPROBES_HOOK + +static int sys_execve_handler_pre(struct kprobe *p, struct pt_regs *regs) +{ + struct pt_regs *real_regs = PT_REAL_REGS(regs); + const char __user **filename_user = + (const char **)&PT_REGS_PARM1(real_regs); + const char __user *const __user *__argv = + (const char __user *const __user *)PT_REGS_PARM2(real_regs); + struct user_arg_ptr argv = { .ptr.native = __argv }; + struct filename filename_in, *filename_p; + char path[32]; + + if (!filename_user) + return 0; + + memset(path, 0, sizeof(path)); + strncpy_from_user_nofault(path, *filename_user, 32); + filename_in.name = path; + + filename_p = &filename_in; + return ksu_handle_execveat_ksud(AT_FDCWD, &filename_p, &argv, NULL, NULL); +} + +static int sys_read_handler_pre(struct kprobe *p, struct pt_regs *regs) +{ + struct pt_regs *real_regs = PT_REAL_REGS(regs); + unsigned int fd = PT_REGS_PARM1(real_regs); + char __user **buf_ptr = (char __user **)&PT_REGS_PARM2(real_regs); + size_t count_ptr = (size_t *)&PT_REGS_PARM3(real_regs); + + return ksu_handle_sys_read(fd, buf_ptr, count_ptr); +} + +static int input_handle_event_handler_pre(struct kprobe *p, + struct pt_regs *regs) +{ + unsigned int *type = (unsigned int *)&PT_REGS_PARM2(regs); + unsigned int *code = (unsigned int *)&PT_REGS_PARM3(regs); + int *value = (int *)&PT_REGS_CCALL_PARM4(regs); + return ksu_handle_input_handle_event(type, code, value); +} + +static struct kprobe execve_kp = { + .symbol_name = SYS_EXECVE_SYMBOL, + .pre_handler = sys_execve_handler_pre, +}; + +static struct kprobe vfs_read_kp = { + .symbol_name = SYS_READ_SYMBOL, + .pre_handler = sys_read_handler_pre, +}; + +static struct kprobe input_event_kp = { + .symbol_name = "input_event", + .pre_handler = input_handle_event_handler_pre, +}; + +static void do_stop_vfs_read_hook(struct work_struct *work) +{ + unregister_kprobe(&vfs_read_kp); +} + +static void do_stop_execve_hook(struct work_struct *work) +{ + unregister_kprobe(&execve_kp); +} + +static void do_stop_input_hook(struct work_struct *work) +{ + unregister_kprobe(&input_event_kp); +} +#endif + +static void stop_vfs_read_hook() +{ +#ifdef KSU_KPROBES_HOOK + bool ret = schedule_work(&stop_vfs_read_work); + pr_info("unregister vfs_read kprobe: %d!\n", ret); +#else + ksu_vfs_read_hook = false; + pr_info("stop vfs_read_hook\n"); +#endif +} + +static void stop_execve_hook() +{ +#ifdef KSU_KPROBES_HOOK + bool ret = schedule_work(&stop_execve_hook_work); + pr_info("unregister execve kprobe: %d!\n", ret); +#else + ksu_execveat_hook = false; + pr_info("stop execve_hook\n"); +#endif +} + +static void stop_input_hook() +{ + static bool input_hook_stopped = false; + if (input_hook_stopped) { + return; + } + input_hook_stopped = true; +#ifdef KSU_KPROBES_HOOK + bool ret = schedule_work(&stop_input_hook_work); + pr_info("unregister input kprobe: %d!\n", ret); +#else + ksu_input_hook = false; + pr_info("stop input_hook\n"); +#endif +} + +// ksud: module support +void ksu_ksud_init() +{ +#ifdef KSU_KPROBES_HOOK + int ret; + + ret = register_kprobe(&execve_kp); + pr_info("ksud: execve_kp: %d\n", ret); + + ret = register_kprobe(&vfs_read_kp); + pr_info("ksud: vfs_read_kp: %d\n", ret); + + ret = register_kprobe(&input_event_kp); + pr_info("ksud: input_event_kp: %d\n", ret); + + INIT_WORK(&stop_vfs_read_work, do_stop_vfs_read_hook); + INIT_WORK(&stop_execve_hook_work, do_stop_execve_hook); + INIT_WORK(&stop_input_hook_work, do_stop_input_hook); +#endif +} + +void ksu_ksud_exit() +{ +#ifdef KSU_KPROBES_HOOK + unregister_kprobe(&execve_kp); + // this should be done before unregister vfs_read_kp + // unregister_kprobe(&vfs_read_kp); + unregister_kprobe(&input_event_kp); +#endif + is_boot_phase = false; +} diff --git a/kernel/ksud.h b/kernel/ksud.h new file mode 100644 index 0000000..2ee62e9 --- /dev/null +++ b/kernel/ksud.h @@ -0,0 +1,21 @@ +#ifndef __KSU_H_KSUD +#define __KSU_H_KSUD + +#define KSUD_PATH "/data/adb/ksud" + +void ksu_ksud_init(); +void ksu_ksud_exit(); + +void on_post_fs_data(void); +void on_module_mounted(void); +void on_boot_completed(void); + +bool ksu_is_safe_mode(void); + +int nuke_ext4_sysfs(const char* mnt); + +extern u32 ksu_file_sid; +extern bool ksu_module_mounted; +extern bool ksu_boot_completed; + +#endif diff --git a/kernel/manager.h b/kernel/manager.h new file mode 100644 index 0000000..2421da7 --- /dev/null +++ b/kernel/manager.h @@ -0,0 +1,43 @@ +#ifndef __KSU_H_KSU_MANAGER +#define __KSU_H_KSU_MANAGER + +#include +#include + +#define KSU_INVALID_UID -1 + +extern uid_t ksu_manager_uid; // DO NOT DIRECT USE + +extern bool ksu_is_any_manager(uid_t uid); +extern void ksu_add_manager(uid_t uid, int signature_index); +extern void ksu_remove_manager(uid_t uid); +extern int ksu_get_manager_signature_index(uid_t uid); + +static inline bool ksu_is_manager_uid_valid(void) +{ + return ksu_manager_uid != KSU_INVALID_UID; +} + +static inline bool is_manager(void) +{ + return unlikely(ksu_is_any_manager(current_uid().val) || + (ksu_manager_uid != KSU_INVALID_UID && ksu_manager_uid == current_uid().val)); +} + +static inline uid_t ksu_get_manager_uid(void) +{ + return ksu_manager_uid; +} + +static inline void ksu_set_manager_uid(uid_t uid) +{ + ksu_manager_uid = uid; +} + +static inline void ksu_invalidate_manager_uid(void) +{ + ksu_manager_uid = KSU_INVALID_UID; +} + +int ksu_observer_init(void); +#endif diff --git a/kernel/manager_sign.h b/kernel/manager_sign.h new file mode 100644 index 0000000..265ef35 --- /dev/null +++ b/kernel/manager_sign.h @@ -0,0 +1,17 @@ +#ifndef MANAGER_SIGN_H +#define MANAGER_SIGN_H + +// ShirkNeko/SukiSU +#define EXPECTED_SIZE_SHIRKNEKO 0x35c +#define EXPECTED_HASH_SHIRKNEKO "947ae944f3de4ed4c21a7e4f7953ecf351bfa2b36239da37a34111ad29993eef" + +// Dynamic Sign +#define EXPECTED_SIZE_OTHER 0x300 +#define EXPECTED_HASH_OTHER "0000000000000000000000000000000000000000000000000000000000000000" + +typedef struct { + unsigned size; + const char *sha256; +} apk_sign_key_t; + +#endif /* MANAGER_SIGN_H */ diff --git a/kernel/manual_su.c b/kernel/manual_su.c new file mode 100644 index 0000000..dfb3a25 --- /dev/null +++ b/kernel/manual_su.c @@ -0,0 +1,357 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "manual_su.h" +#include "ksu.h" +#include "allowlist.h" +#include "manager.h" +#include "app_profile.h" + +static bool current_verified = false; +static void ksu_cleanup_expired_tokens(void); +static bool is_current_verified(void); +static void add_pending_root(uid_t uid); + +static struct pending_uid pending_uids[MAX_PENDING] = {0}; +static int pending_cnt = 0; +static struct ksu_token_entry auth_tokens[MAX_TOKENS] = {0}; +static int token_count = 0; +static DEFINE_SPINLOCK(token_lock); + +static char* get_token_from_envp(void) +{ + struct mm_struct *mm; + char *envp_start, *envp_end; + char *env_ptr, *token = NULL; + unsigned long env_len; + char *env_copy = NULL; + + if (!current->mm) + return NULL; + + mm = current->mm; + + down_read(&mm->mmap_lock); + + envp_start = (char *)mm->env_start; + envp_end = (char *)mm->env_end; + env_len = envp_end - envp_start; + + if (env_len <= 0 || env_len > PAGE_SIZE * 32) { + up_read(&mm->mmap_lock); + return NULL; + } + + env_copy = kzalloc(env_len + 1, GFP_KERNEL); + if (!env_copy) { + up_read(&mm->mmap_lock); + return NULL; + } + + if (copy_from_user(env_copy, envp_start, env_len)) { + kfree(env_copy); + up_read(&mm->mmap_lock); + return NULL; + } + + up_read(&mm->mmap_lock); + + env_copy[env_len] = '\0'; + env_ptr = env_copy; + + while (env_ptr < env_copy + env_len) { + if (strncmp(env_ptr, KSU_TOKEN_ENV_NAME "=", strlen(KSU_TOKEN_ENV_NAME) + 1) == 0) { + char *token_start = env_ptr + strlen(KSU_TOKEN_ENV_NAME) + 1; + char *token_end = strchr(token_start, '\0'); + + if (token_end && (token_end - token_start) == KSU_TOKEN_LENGTH) { + token = kzalloc(KSU_TOKEN_LENGTH + 1, GFP_KERNEL); + if (token) { + memcpy(token, token_start, KSU_TOKEN_LENGTH); + token[KSU_TOKEN_LENGTH] = '\0'; + pr_info("manual_su: found auth token in environment\n"); + } + } + break; + } + + env_ptr += strlen(env_ptr) + 1; + } + + kfree(env_copy); + return token; +} + +static char* ksu_generate_auth_token(void) +{ + static char token_buffer[KSU_TOKEN_LENGTH + 1]; + unsigned long flags; + int i; + + ksu_cleanup_expired_tokens(); + + spin_lock_irqsave(&token_lock, flags); + + if (token_count >= MAX_TOKENS) { + for (i = 0; i < MAX_TOKENS - 1; i++) { + auth_tokens[i] = auth_tokens[i + 1]; + } + token_count = MAX_TOKENS - 1; + } + + for (i = 0; i < KSU_TOKEN_LENGTH; i++) { + u8 rand_byte; + get_random_bytes(&rand_byte, 1); + int char_type = rand_byte % 3; + if (char_type == 0) { + token_buffer[i] = 'A' + (rand_byte % 26); + } else if (char_type == 1) { + token_buffer[i] = 'a' + (rand_byte % 26); + } else { + token_buffer[i] = '0' + (rand_byte % 10); + } + } + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0) + strscpy(auth_tokens[token_count].token, token_buffer, KSU_TOKEN_LENGTH + 1); +#else + strlcpy(auth_tokens[token_count].token, token_buffer, KSU_TOKEN_LENGTH + 1); +#endif + auth_tokens[token_count].expire_time = jiffies + KSU_TOKEN_EXPIRE_TIME * HZ; + auth_tokens[token_count].used = false; + token_count++; + + spin_unlock_irqrestore(&token_lock, flags); + + pr_info("manual_su: generated new auth token (expires in %d seconds)\n", KSU_TOKEN_EXPIRE_TIME); + return token_buffer; +} + +static bool ksu_verify_auth_token(const char *token) +{ + unsigned long flags; + bool valid = false; + int i; + + if (!token || strlen(token) != KSU_TOKEN_LENGTH) { + return false; + } + + spin_lock_irqsave(&token_lock, flags); + + for (i = 0; i < token_count; i++) { + if (!auth_tokens[i].used && + time_before(jiffies, auth_tokens[i].expire_time) && + strcmp(auth_tokens[i].token, token) == 0) { + + auth_tokens[i].used = true; + valid = true; + pr_info("manual_su: auth token verified successfully\n"); + break; + } + } + + spin_unlock_irqrestore(&token_lock, flags); + + if (!valid) { + pr_warn("manual_su: invalid or expired auth token\n"); + } + + return valid; +} + +static void ksu_cleanup_expired_tokens(void) +{ + unsigned long flags; + int i, j; + + spin_lock_irqsave(&token_lock, flags); + + for (i = 0; i < token_count; ) { + if (time_after(jiffies, auth_tokens[i].expire_time) || auth_tokens[i].used) { + for (j = i; j < token_count - 1; j++) { + auth_tokens[j] = auth_tokens[j + 1]; + } + token_count--; + pr_debug("manual_su: cleaned up expired/used token\n"); + } else { + i++; + } + } + + spin_unlock_irqrestore(&token_lock, flags); +} + +static int handle_token_generation(struct manual_su_request *request) +{ + if (current_uid().val > 2000) { + pr_warn("manual_su: token generation denied for app UID %d\n", current_uid().val); + return -EPERM; + } + + char *new_token = ksu_generate_auth_token(); + if (!new_token) { + pr_err("manual_su: failed to generate token\n"); + return -ENOMEM; + } + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0) + strscpy(request->token_buffer, new_token, KSU_TOKEN_LENGTH + 1); +#else + strlcpy(request->token_buffer, new_token, KSU_TOKEN_LENGTH + 1); +#endif + + pr_info("manual_su: auth token generated successfully\n"); + return 0; +} + +static int handle_escalation_request(struct manual_su_request *request) +{ + uid_t target_uid = request->target_uid; + pid_t target_pid = request->target_pid; + struct task_struct *tsk; + + rcu_read_lock(); + tsk = pid_task(find_vpid(target_pid), PIDTYPE_PID); + if (!tsk || ksu_task_is_dead(tsk)) { + rcu_read_unlock(); + pr_err("cmd_su: PID %d is invalid or dead\n", target_pid); + return -ESRCH; + } + rcu_read_unlock(); + + if (current_uid().val == 0 || is_manager() || ksu_is_allow_uid_for_current(current_uid().val)) + goto allowed; + + char *env_token = get_token_from_envp(); + if (!env_token) { + pr_warn("manual_su: no auth token found in environment\n"); + return -EACCES; + } + + bool token_valid = ksu_verify_auth_token(env_token); + kfree(env_token); + + if (!token_valid) { + pr_warn("manual_su: token verification failed\n"); + return -EACCES; + } + +allowed: + current_verified = true; + escape_to_root_for_cmd_su(target_uid, target_pid); + return 0; +} + +static int handle_add_pending_request(struct manual_su_request *request) +{ + uid_t target_uid = request->target_uid; + + if (!is_current_verified()) { + pr_warn("manual_su: add_pending denied, not verified\n"); + return -EPERM; + } + + add_pending_root(target_uid); + current_verified = false; + pr_info("manual_su: pending root added for UID %d\n", target_uid); + return 0; +} + +int ksu_handle_manual_su_request(int option, struct manual_su_request *request) +{ + if (!request) { + pr_err("manual_su: invalid request pointer\n"); + return -EINVAL; + } + + switch (option) { + case MANUAL_SU_OP_GENERATE_TOKEN: + pr_info("manual_su: handling token generation request\n"); + return handle_token_generation(request); + + case MANUAL_SU_OP_ESCALATE: + pr_info("manual_su: handling escalation request for UID %d, PID %d\n", + request->target_uid, request->target_pid); + return handle_escalation_request(request); + + case MANUAL_SU_OP_ADD_PENDING: + pr_info("manual_su: handling add pending request for UID %d\n", request->target_uid); + return handle_add_pending_request(request); + + default: + pr_err("manual_su: unknown option %d\n", option); + return -EINVAL; + } +} + +static bool is_current_verified(void) +{ + return current_verified; +} + +bool is_pending_root(uid_t uid) +{ + for (int i = 0; i < pending_cnt; i++) { + if (pending_uids[i].uid == uid) { + pending_uids[i].use_count++; + pending_uids[i].remove_calls++; + return true; + } + } + return false; +} + +void remove_pending_root(uid_t uid) +{ + for (int i = 0; i < pending_cnt; i++) { + if (pending_uids[i].uid == uid) { + pending_uids[i].remove_calls++; + + if (pending_uids[i].remove_calls >= REMOVE_DELAY_CALLS) { + pending_uids[i] = pending_uids[--pending_cnt]; + pr_info("pending_root: removed UID %d after %d calls\n", uid, REMOVE_DELAY_CALLS); + ksu_temp_revoke_root_once(uid); + } else { + pr_info("pending_root: UID %d remove_call=%d (<%d)\n", + uid, pending_uids[i].remove_calls, REMOVE_DELAY_CALLS); + } + return; + } + } +} + +static void add_pending_root(uid_t uid) +{ + if (pending_cnt >= MAX_PENDING) { + pr_warn("pending_root: cache full\n"); + return; + } + for (int i = 0; i < pending_cnt; i++) { + if (pending_uids[i].uid == uid) { + pending_uids[i].use_count = 0; + pending_uids[i].remove_calls = 0; + return; + } + } + pending_uids[pending_cnt++] = (struct pending_uid){uid, 0}; + ksu_temp_grant_root_once(uid); + pr_info("pending_root: cached UID %d\n", uid); +} + +void ksu_try_escalate_for_uid(uid_t uid) +{ + if (!is_pending_root(uid)) + return; + + pr_info("pending_root: UID=%d temporarily allowed\n", uid); + remove_pending_root(uid); +} \ No newline at end of file diff --git a/kernel/manual_su.h b/kernel/manual_su.h new file mode 100644 index 0000000..419dbfc --- /dev/null +++ b/kernel/manual_su.h @@ -0,0 +1,49 @@ +#ifndef __KSU_MANUAL_SU_H +#define __KSU_MANUAL_SU_H + +#include +#include +#include + +#if LINUX_VERSION_CODE < KERNEL_VERSION(5, 7, 0) +#define mmap_lock mmap_sem +#endif + +#define ksu_task_is_dead(t) ((t)->exit_state != 0) + +#define MAX_PENDING 16 +#define REMOVE_DELAY_CALLS 150 +#define MAX_TOKENS 10 + +#define KSU_SU_VERIFIED_BIT (1UL << 0) +#define KSU_TOKEN_LENGTH 32 +#define KSU_TOKEN_ENV_NAME "KSU_AUTH_TOKEN" +#define KSU_TOKEN_EXPIRE_TIME 150 + +#define MANUAL_SU_OP_GENERATE_TOKEN 0 +#define MANUAL_SU_OP_ESCALATE 1 +#define MANUAL_SU_OP_ADD_PENDING 2 + +struct pending_uid { + uid_t uid; + int use_count; + int remove_calls; +}; + +struct manual_su_request { + uid_t target_uid; + pid_t target_pid; + char token_buffer[KSU_TOKEN_LENGTH + 1]; +}; + +struct ksu_token_entry { + char token[KSU_TOKEN_LENGTH + 1]; + unsigned long expire_time; + bool used; +}; + +int ksu_handle_manual_su_request(int option, struct manual_su_request *request); +bool is_pending_root(uid_t uid); +void remove_pending_root(uid_t uid); +void ksu_try_escalate_for_uid(uid_t uid); +#endif \ No newline at end of file diff --git a/kernel/pkg_observer.c b/kernel/pkg_observer.c new file mode 100644 index 0000000..b632cd1 --- /dev/null +++ b/kernel/pkg_observer.c @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-2.0 +#include +#include +#include +#include +#include +#include +#include +#include "klog.h" // IWYU pragma: keep +#include "ksu.h" +#include "throne_tracker.h" +#include "throne_comm.h" + +#define MASK_SYSTEM (FS_CREATE | FS_MOVE | FS_EVENT_ON_CHILD) + +struct watch_dir { + const char *path; + u32 mask; + struct path kpath; + struct inode *inode; + struct fsnotify_mark *mark; +}; + +static struct fsnotify_group *g; + +static int ksu_handle_inode_event(struct fsnotify_mark *mark, u32 mask, + struct inode *inode, struct inode *dir, + const struct qstr *file_name, u32 cookie) +{ + if (!file_name) + return 0; + if (mask & FS_ISDIR) + return 0; + if (file_name->len == 13 && + !memcmp(file_name->name, "packages.list", 13)) { + pr_info("packages.list detected: %d\n", mask); + if (ksu_uid_scanner_enabled) { + ksu_request_userspace_scan(); + } + track_throne(false); + } + return 0; +} + +static const struct fsnotify_ops ksu_ops = { + .handle_inode_event = ksu_handle_inode_event, +}; + +static int add_mark_on_inode(struct inode *inode, u32 mask, + struct fsnotify_mark **out) +{ + struct fsnotify_mark *m; + + m = kzalloc(sizeof(*m), GFP_KERNEL); + if (!m) + return -ENOMEM; + + fsnotify_init_mark(m, g); + m->mask = mask; + + if (fsnotify_add_inode_mark(m, inode, 0)) { + fsnotify_put_mark(m); + return -EINVAL; + } + *out = m; + return 0; +} + +static int watch_one_dir(struct watch_dir *wd) +{ + int ret = kern_path(wd->path, LOOKUP_FOLLOW, &wd->kpath); + if (ret) { + pr_info("path not ready: %s (%d)\n", wd->path, ret); + return ret; + } + wd->inode = d_inode(wd->kpath.dentry); + ihold(wd->inode); + + ret = add_mark_on_inode(wd->inode, wd->mask, &wd->mark); + if (ret) { + pr_err("Add mark failed for %s (%d)\n", wd->path, ret); + path_put(&wd->kpath); + iput(wd->inode); + wd->inode = NULL; + return ret; + } + pr_info("watching %s\n", wd->path); + return 0; +} + +static void unwatch_one_dir(struct watch_dir *wd) +{ + if (wd->mark) { + fsnotify_destroy_mark(wd->mark, g); + fsnotify_put_mark(wd->mark); + wd->mark = NULL; + } + if (wd->inode) { + iput(wd->inode); + wd->inode = NULL; + } + if (wd->kpath.dentry) { + path_put(&wd->kpath); + memset(&wd->kpath, 0, sizeof(wd->kpath)); + } +} + +static struct watch_dir g_watch = { .path = "/data/system", + .mask = MASK_SYSTEM }; + +int ksu_observer_init(void) +{ + int ret = 0; + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 0, 0) + g = fsnotify_alloc_group(&ksu_ops, 0); +#else + g = fsnotify_alloc_group(&ksu_ops); +#endif + if (IS_ERR(g)) + return PTR_ERR(g); + + ret = watch_one_dir(&g_watch); + pr_info("observer init done\n"); + return 0; +} + +void ksu_observer_exit(void) +{ + unwatch_one_dir(&g_watch); + fsnotify_put_group(g); + pr_info("observer exit done\n"); +} \ No newline at end of file diff --git a/kernel/seccomp_cache.c b/kernel/seccomp_cache.c new file mode 100644 index 0000000..286b5ca --- /dev/null +++ b/kernel/seccomp_cache.c @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include +#include +#include +#include "klog.h" // IWYU pragma: keep +#include "seccomp_cache.h" + + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 10, 2) // Android backport this feature in 5.10.2 +struct action_cache { + DECLARE_BITMAP(allow_native, SECCOMP_ARCH_NATIVE_NR); +#ifdef SECCOMP_ARCH_COMPAT + DECLARE_BITMAP(allow_compat, SECCOMP_ARCH_COMPAT_NR); +#endif +}; + +struct seccomp_filter { + refcount_t refs; + refcount_t users; + bool log; +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 1, 0) + bool wait_killable_recv; +#endif + struct action_cache cache; + struct seccomp_filter *prev; + struct bpf_prog *prog; + struct notification *notif; + struct mutex notify_lock; + wait_queue_head_t wqh; +}; + +void ksu_seccomp_clear_cache(struct seccomp_filter *filter, int nr) +{ + if (!filter) { + return; + } + + if (nr >= 0 && nr < SECCOMP_ARCH_NATIVE_NR) { + clear_bit(nr, filter->cache.allow_native); + } + +#ifdef SECCOMP_ARCH_COMPAT + if (nr >= 0 && nr < SECCOMP_ARCH_COMPAT_NR) { + clear_bit(nr, filter->cache.allow_compat); + } +#endif +} + +void ksu_seccomp_allow_cache(struct seccomp_filter *filter, int nr) +{ + if (!filter) { + return; + } + + if (nr >= 0 && nr < SECCOMP_ARCH_NATIVE_NR) { + set_bit(nr, filter->cache.allow_native); + } + +#ifdef SECCOMP_ARCH_COMPAT + if (nr >= 0 && nr < SECCOMP_ARCH_COMPAT_NR) { + set_bit(nr, filter->cache.allow_compat); + } +#endif +} + +#endif \ No newline at end of file diff --git a/kernel/seccomp_cache.h b/kernel/seccomp_cache.h new file mode 100644 index 0000000..ce88328 --- /dev/null +++ b/kernel/seccomp_cache.h @@ -0,0 +1,12 @@ +#ifndef __KSU_H_SECCOMP_CACHE +#define __KSU_H_SECCOMP_CACHE + +#include +#include + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 10, 2) // Android backport this feature in 5.10.2 +extern void ksu_seccomp_clear_cache(struct seccomp_filter *filter, int nr); +extern void ksu_seccomp_allow_cache(struct seccomp_filter *filter, int nr); +#endif + +#endif \ No newline at end of file diff --git a/kernel/selinux/Makefile b/kernel/selinux/Makefile new file mode 100644 index 0000000..d35413d --- /dev/null +++ b/kernel/selinux/Makefile @@ -0,0 +1,8 @@ +obj-y += selinux.o +obj-y += sepolicy.o +obj-y += rules.o + +ccflags-y += -Wno-strict-prototypes -Wno-int-conversion +ccflags-y += -Wno-declaration-after-statement -Wno-unused-function +ccflags-y += -I$(srctree)/security/selinux -I$(srctree)/security/selinux/include +ccflags-y += -I$(objtree)/security/selinux -include $(srctree)/include/uapi/asm-generic/errno.h diff --git a/kernel/selinux/rules.c b/kernel/selinux/rules.c new file mode 100644 index 0000000..98d7475 --- /dev/null +++ b/kernel/selinux/rules.c @@ -0,0 +1,477 @@ +#include +#include +#include + +#include "../klog.h" // IWYU pragma: keep +#include "selinux.h" +#include "sepolicy.h" +#include "ss/services.h" +#include "linux/lsm_audit.h" // IWYU pragma: keep +#include "xfrm.h" + +#define SELINUX_POLICY_INSTEAD_SELINUX_SS + +#define KERNEL_SU_DOMAIN "su" +#define KERNEL_SU_FILE "ksu_file" +#define KERNEL_EXEC_TYPE "ksu_exec" +#define ALL NULL + +static struct policydb *get_policydb(void) +{ + struct policydb *db; + struct selinux_policy *policy = selinux_state.policy; + db = &policy->policydb; + return db; +} + +static DEFINE_MUTEX(ksu_rules); + +void apply_kernelsu_rules() +{ + struct policydb *db; + + if (!getenforce()) { + pr_info("SELinux permissive or disabled, apply rules!\n"); + } + + mutex_lock(&ksu_rules); + + db = get_policydb(); + + ksu_permissive(db, KERNEL_SU_DOMAIN); + ksu_typeattribute(db, KERNEL_SU_DOMAIN, "mlstrustedsubject"); + ksu_typeattribute(db, KERNEL_SU_DOMAIN, "netdomain"); + ksu_typeattribute(db, KERNEL_SU_DOMAIN, "bluetoothdomain"); + + // Create unconstrained file type + ksu_type(db, KERNEL_SU_FILE, "file_type"); + ksu_typeattribute(db, KERNEL_SU_FILE, "mlstrustedobject"); + ksu_allow(db, ALL, KERNEL_SU_FILE, ALL, ALL); + + // allow all! + ksu_allow(db, KERNEL_SU_DOMAIN, ALL, ALL, ALL); + + // allow us do any ioctl + if (db->policyvers >= POLICYDB_VERSION_XPERMS_IOCTL) { + ksu_allowxperm(db, KERNEL_SU_DOMAIN, ALL, "blk_file", ALL); + ksu_allowxperm(db, KERNEL_SU_DOMAIN, ALL, "fifo_file", ALL); + ksu_allowxperm(db, KERNEL_SU_DOMAIN, ALL, "chr_file", ALL); + ksu_allowxperm(db, KERNEL_SU_DOMAIN, ALL, "file", ALL); + } + + // we need to save allowlist in /data/adb/ksu + ksu_allow(db, "kernel", "adb_data_file", "dir", ALL); + ksu_allow(db, "kernel", "adb_data_file", "file", ALL); + // we need to search /data/app + ksu_allow(db, "kernel", "apk_data_file", "file", "open"); + ksu_allow(db, "kernel", "apk_data_file", "dir", "open"); + ksu_allow(db, "kernel", "apk_data_file", "dir", "read"); + ksu_allow(db, "kernel", "apk_data_file", "dir", "search"); + // we may need to do mount on shell + ksu_allow(db, "kernel", "shell_data_file", "file", ALL); + // we need to read /data/system/packages.list + ksu_allow(db, "kernel", "kernel", "capability", "dac_override"); + // Android 10+: + // http://aospxref.com/android-12.0.0_r3/xref/system/sepolicy/private/file_contexts#512 + ksu_allow(db, "kernel", "packages_list_file", "file", ALL); + // Kernel 4.4 + ksu_allow(db, "kernel", "packages_list_file", "dir", ALL); + // Android 9-: + // http://aospxref.com/android-9.0.0_r61/xref/system/sepolicy/private/file_contexts#360 + ksu_allow(db, "kernel", "system_data_file", "file", ALL); + ksu_allow(db, "kernel", "system_data_file", "dir", ALL); + // our ksud triggered by init + ksu_allow(db, "init", "adb_data_file", "file", ALL); + ksu_allow(db, "init", "adb_data_file", "dir", ALL); // #1289 + ksu_allow(db, "init", KERNEL_SU_DOMAIN, ALL, ALL); + // we need to umount modules in zygote + ksu_allow(db, "zygote", "adb_data_file", "dir", "search"); + + // copied from Magisk rules + // suRights + ksu_allow(db, "servicemanager", KERNEL_SU_DOMAIN, "dir", "search"); + ksu_allow(db, "servicemanager", KERNEL_SU_DOMAIN, "dir", "read"); + ksu_allow(db, "servicemanager", KERNEL_SU_DOMAIN, "file", "open"); + ksu_allow(db, "servicemanager", KERNEL_SU_DOMAIN, "file", "read"); + ksu_allow(db, "servicemanager", KERNEL_SU_DOMAIN, "process", "getattr"); + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "process", "sigchld"); + + // allowLog + ksu_allow(db, "logd", KERNEL_SU_DOMAIN, "dir", "search"); + ksu_allow(db, "logd", KERNEL_SU_DOMAIN, "file", "read"); + ksu_allow(db, "logd", KERNEL_SU_DOMAIN, "file", "open"); + ksu_allow(db, "logd", KERNEL_SU_DOMAIN, "file", "getattr"); + + // dumpsys + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "fd", "use"); + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "fifo_file", "write"); + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "fifo_file", "read"); + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "fifo_file", "open"); + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "fifo_file", "getattr"); + + // bootctl + ksu_allow(db, "hwservicemanager", KERNEL_SU_DOMAIN, "dir", "search"); + ksu_allow(db, "hwservicemanager", KERNEL_SU_DOMAIN, "file", "read"); + ksu_allow(db, "hwservicemanager", KERNEL_SU_DOMAIN, "file", "open"); + ksu_allow(db, "hwservicemanager", KERNEL_SU_DOMAIN, "process", + "getattr"); + + // For mounting loop devices, mirrors, tmpfs + ksu_allow(db, "kernel", ALL, "file", "read"); + ksu_allow(db, "kernel", ALL, "file", "write"); + + // Allow all binder transactions + ksu_allow(db, ALL, KERNEL_SU_DOMAIN, "binder", ALL); + + // Allow system server kill su process + ksu_allow(db, "system_server", KERNEL_SU_DOMAIN, "process", "getpgid"); + ksu_allow(db, "system_server", KERNEL_SU_DOMAIN, "process", "sigkill"); + + // https://android-review.googlesource.com/c/platform/system/logging/+/3725346 + ksu_dontaudit(db, "untrusted_app", KERNEL_SU_DOMAIN, "dir", "getattr"); + + mutex_unlock(&ksu_rules); +} + +#define MAX_SEPOL_LEN 128 + +#define CMD_NORMAL_PERM 1 +#define CMD_XPERM 2 +#define CMD_TYPE_STATE 3 +#define CMD_TYPE 4 +#define CMD_TYPE_ATTR 5 +#define CMD_ATTR 6 +#define CMD_TYPE_TRANSITION 7 +#define CMD_TYPE_CHANGE 8 +#define CMD_GENFSCON 9 + +struct sepol_data { + u32 cmd; + u32 subcmd; + char __user *sepol1; + char __user *sepol2; + char __user *sepol3; + char __user *sepol4; + char __user *sepol5; + char __user *sepol6; + char __user *sepol7; +}; + +static int get_object(char *buf, char __user *user_object, size_t buf_sz, + char **object) +{ + if (!user_object) { + *object = ALL; + return 0; + } + + if (strncpy_from_user(buf, user_object, buf_sz) < 0) { + return -EINVAL; + } + + *object = buf; + + return 0; +} +#if (LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0)) +extern int avc_ss_reset(u32 seqno); +#else +extern int avc_ss_reset(struct selinux_avc *avc, u32 seqno); +#endif +// reset avc cache table, otherwise the new rules will not take effect if already denied +static void reset_avc_cache() +{ +#if (LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0)) + avc_ss_reset(0); + selnl_notify_policyload(0); + selinux_status_update_policyload(0); +#else + struct selinux_avc *avc = selinux_state.avc; + avc_ss_reset(avc, 0); + selnl_notify_policyload(0); + selinux_status_update_policyload(&selinux_state, 0); +#endif + selinux_xfrm_notify_policyload(); +} + +int handle_sepolicy(unsigned long arg3, void __user *arg4) +{ + struct policydb *db; + + if (!arg4) { + return -EINVAL; + } + + if (!getenforce()) { + pr_info("SELinux permissive or disabled when handle policy!\n"); + } + + struct sepol_data data; + if (copy_from_user(&data, arg4, sizeof(struct sepol_data))) { + pr_err("sepol: copy sepol_data failed.\n"); + return -EINVAL; + } + + u32 cmd = data.cmd; + u32 subcmd = data.subcmd; + + mutex_lock(&ksu_rules); + + db = get_policydb(); + + int ret = -EINVAL; + if (cmd == CMD_NORMAL_PERM) { + char src_buf[MAX_SEPOL_LEN]; + char tgt_buf[MAX_SEPOL_LEN]; + char cls_buf[MAX_SEPOL_LEN]; + char perm_buf[MAX_SEPOL_LEN]; + + char *s, *t, *c, *p; + if (get_object(src_buf, data.sepol1, sizeof(src_buf), &s) < 0) { + pr_err("sepol: copy src failed.\n"); + goto exit; + } + + if (get_object(tgt_buf, data.sepol2, sizeof(tgt_buf), &t) < 0) { + pr_err("sepol: copy tgt failed.\n"); + goto exit; + } + + if (get_object(cls_buf, data.sepol3, sizeof(cls_buf), &c) < 0) { + pr_err("sepol: copy cls failed.\n"); + goto exit; + } + + if (get_object(perm_buf, data.sepol4, sizeof(perm_buf), &p) < + 0) { + pr_err("sepol: copy perm failed.\n"); + goto exit; + } + + bool success = false; + if (subcmd == 1) { + success = ksu_allow(db, s, t, c, p); + } else if (subcmd == 2) { + success = ksu_deny(db, s, t, c, p); + } else if (subcmd == 3) { + success = ksu_auditallow(db, s, t, c, p); + } else if (subcmd == 4) { + success = ksu_dontaudit(db, s, t, c, p); + } else { + pr_err("sepol: unknown subcmd: %d\n", subcmd); + } + ret = success ? 0 : -EINVAL; + + } else if (cmd == CMD_XPERM) { + char src_buf[MAX_SEPOL_LEN]; + char tgt_buf[MAX_SEPOL_LEN]; + char cls_buf[MAX_SEPOL_LEN]; + + char __maybe_unused + operation[MAX_SEPOL_LEN]; // it is always ioctl now! + char perm_set[MAX_SEPOL_LEN]; + + char *s, *t, *c; + if (get_object(src_buf, data.sepol1, sizeof(src_buf), &s) < 0) { + pr_err("sepol: copy src failed.\n"); + goto exit; + } + if (get_object(tgt_buf, data.sepol2, sizeof(tgt_buf), &t) < 0) { + pr_err("sepol: copy tgt failed.\n"); + goto exit; + } + if (get_object(cls_buf, data.sepol3, sizeof(cls_buf), &c) < 0) { + pr_err("sepol: copy cls failed.\n"); + goto exit; + } + if (strncpy_from_user(operation, data.sepol4, + sizeof(operation)) < 0) { + pr_err("sepol: copy operation failed.\n"); + goto exit; + } + if (strncpy_from_user(perm_set, data.sepol5, sizeof(perm_set)) < + 0) { + pr_err("sepol: copy perm_set failed.\n"); + goto exit; + } + + bool success = false; + if (subcmd == 1) { + success = ksu_allowxperm(db, s, t, c, perm_set); + } else if (subcmd == 2) { + success = ksu_auditallowxperm(db, s, t, c, perm_set); + } else if (subcmd == 3) { + success = ksu_dontauditxperm(db, s, t, c, perm_set); + } else { + pr_err("sepol: unknown subcmd: %d\n", subcmd); + } + ret = success ? 0 : -EINVAL; + } else if (cmd == CMD_TYPE_STATE) { + char src[MAX_SEPOL_LEN]; + + if (strncpy_from_user(src, data.sepol1, sizeof(src)) < 0) { + pr_err("sepol: copy src failed.\n"); + goto exit; + } + + bool success = false; + if (subcmd == 1) { + success = ksu_permissive(db, src); + } else if (subcmd == 2) { + success = ksu_enforce(db, src); + } else { + pr_err("sepol: unknown subcmd: %d\n", subcmd); + } + if (success) + ret = 0; + + } else if (cmd == CMD_TYPE || cmd == CMD_TYPE_ATTR) { + char type[MAX_SEPOL_LEN]; + char attr[MAX_SEPOL_LEN]; + + if (strncpy_from_user(type, data.sepol1, sizeof(type)) < 0) { + pr_err("sepol: copy type failed.\n"); + goto exit; + } + if (strncpy_from_user(attr, data.sepol2, sizeof(attr)) < 0) { + pr_err("sepol: copy attr failed.\n"); + goto exit; + } + + bool success = false; + if (cmd == CMD_TYPE) { + success = ksu_type(db, type, attr); + } else { + success = ksu_typeattribute(db, type, attr); + } + if (!success) { + pr_err("sepol: %d failed.\n", cmd); + goto exit; + } + ret = 0; + + } else if (cmd == CMD_ATTR) { + char attr[MAX_SEPOL_LEN]; + + if (strncpy_from_user(attr, data.sepol1, sizeof(attr)) < 0) { + pr_err("sepol: copy attr failed.\n"); + goto exit; + } + if (!ksu_attribute(db, attr)) { + pr_err("sepol: %d failed.\n", cmd); + goto exit; + } + ret = 0; + + } else if (cmd == CMD_TYPE_TRANSITION) { + char src[MAX_SEPOL_LEN]; + char tgt[MAX_SEPOL_LEN]; + char cls[MAX_SEPOL_LEN]; + char default_type[MAX_SEPOL_LEN]; + char object[MAX_SEPOL_LEN]; + + if (strncpy_from_user(src, data.sepol1, sizeof(src)) < 0) { + pr_err("sepol: copy src failed.\n"); + goto exit; + } + if (strncpy_from_user(tgt, data.sepol2, sizeof(tgt)) < 0) { + pr_err("sepol: copy tgt failed.\n"); + goto exit; + } + if (strncpy_from_user(cls, data.sepol3, sizeof(cls)) < 0) { + pr_err("sepol: copy cls failed.\n"); + goto exit; + } + if (strncpy_from_user(default_type, data.sepol4, + sizeof(default_type)) < 0) { + pr_err("sepol: copy default_type failed.\n"); + goto exit; + } + char *real_object; + if (data.sepol5 == NULL) { + real_object = NULL; + } else { + if (strncpy_from_user(object, data.sepol5, + sizeof(object)) < 0) { + pr_err("sepol: copy object failed.\n"); + goto exit; + } + real_object = object; + } + + bool success = ksu_type_transition(db, src, tgt, cls, + default_type, real_object); + if (success) + ret = 0; + + } else if (cmd == CMD_TYPE_CHANGE) { + char src[MAX_SEPOL_LEN]; + char tgt[MAX_SEPOL_LEN]; + char cls[MAX_SEPOL_LEN]; + char default_type[MAX_SEPOL_LEN]; + + if (strncpy_from_user(src, data.sepol1, sizeof(src)) < 0) { + pr_err("sepol: copy src failed.\n"); + goto exit; + } + if (strncpy_from_user(tgt, data.sepol2, sizeof(tgt)) < 0) { + pr_err("sepol: copy tgt failed.\n"); + goto exit; + } + if (strncpy_from_user(cls, data.sepol3, sizeof(cls)) < 0) { + pr_err("sepol: copy cls failed.\n"); + goto exit; + } + if (strncpy_from_user(default_type, data.sepol4, + sizeof(default_type)) < 0) { + pr_err("sepol: copy default_type failed.\n"); + goto exit; + } + bool success = false; + if (subcmd == 1) { + success = ksu_type_change(db, src, tgt, cls, + default_type); + } else if (subcmd == 2) { + success = ksu_type_member(db, src, tgt, cls, + default_type); + } else { + pr_err("sepol: unknown subcmd: %d\n", subcmd); + } + if (success) + ret = 0; + } else if (cmd == CMD_GENFSCON) { + char name[MAX_SEPOL_LEN]; + char path[MAX_SEPOL_LEN]; + char context[MAX_SEPOL_LEN]; + if (strncpy_from_user(name, data.sepol1, sizeof(name)) < 0) { + pr_err("sepol: copy name failed.\n"); + goto exit; + } + if (strncpy_from_user(path, data.sepol2, sizeof(path)) < 0) { + pr_err("sepol: copy path failed.\n"); + goto exit; + } + if (strncpy_from_user(context, data.sepol3, sizeof(context)) < + 0) { + pr_err("sepol: copy context failed.\n"); + goto exit; + } + + if (!ksu_genfscon(db, name, path, context)) { + pr_err("sepol: %d failed.\n", cmd); + goto exit; + } + ret = 0; + } else { + pr_err("sepol: unknown cmd: %d\n", cmd); + } + +exit: + mutex_unlock(&ksu_rules); + + // only allow and xallow needs to reset avc cache, but we cannot do that because + // we are in atomic context. so we just reset it every time. + reset_avc_cache(); + + return ret; +} \ No newline at end of file diff --git a/kernel/selinux/selinux.c b/kernel/selinux/selinux.c new file mode 100644 index 0000000..dfc4831 --- /dev/null +++ b/kernel/selinux/selinux.c @@ -0,0 +1,154 @@ +#include "selinux.h" +#include "linux/cred.h" +#include "linux/sched.h" +#include "objsec.h" +#include "linux/version.h" +#include "../klog.h" // IWYU pragma: keep + +#define KERNEL_SU_DOMAIN "u:r:su:s0" + +static int transive_to_domain(const char *domain) +{ + struct cred *cred; + struct task_security_struct *tsec; + u32 sid; + int error; + + cred = (struct cred *)__task_cred(current); + + tsec = cred->security; + if (!tsec) { + pr_err("tsec == NULL!\n"); + return -1; + } + + error = security_secctx_to_secid(domain, strlen(domain), &sid); + if (error) { + pr_info("security_secctx_to_secid %s -> sid: %d, error: %d\n", + domain, sid, error); + } + if (!error) { + tsec->sid = sid; + tsec->create_sid = 0; + tsec->keycreate_sid = 0; + tsec->sockcreate_sid = 0; + } + return error; +} + +void setup_selinux(const char *domain) +{ + if (transive_to_domain(domain)) { + pr_err("transive domain failed.\n"); + return; + } +} + +void setenforce(bool enforce) +{ +#ifdef CONFIG_SECURITY_SELINUX_DEVELOP + selinux_state.enforcing = enforce; +#endif +} + +bool getenforce() +{ +#ifdef CONFIG_SECURITY_SELINUX_DISABLE + if (selinux_state.disabled) { + return false; + } +#endif + +#ifdef CONFIG_SECURITY_SELINUX_DEVELOP + return selinux_state.enforcing; +#else + return true; +#endif +} + +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 14, 0) +struct lsm_context { + char *context; + u32 len; +}; + +static int __security_secid_to_secctx(u32 secid, struct lsm_context *cp) +{ + return security_secid_to_secctx(secid, &cp->context, &cp->len); +} +static void __security_release_secctx(struct lsm_context *cp) +{ + return security_release_secctx(cp->context, cp->len); +} +#else +#define __security_secid_to_secctx security_secid_to_secctx +#define __security_release_secctx security_release_secctx +#endif + +bool is_task_ksu_domain(const struct cred* cred) +{ + struct lsm_context ctx; + bool result; + if (!cred) { + return false; + } + const struct task_security_struct *tsec = selinux_cred(cred); + if (!tsec) { + return false; + } + int err = __security_secid_to_secctx(tsec->sid, &ctx); + if (err) { + return false; + } + result = strncmp(KERNEL_SU_DOMAIN, ctx.context, ctx.len) == 0; + __security_release_secctx(&ctx); + return result; +} + +bool is_ksu_domain() +{ + current_sid(); + return is_task_ksu_domain(current_cred()); +} + +bool is_context(const struct cred* cred, const char* context) +{ + if (!cred) { + return false; + } + const struct task_security_struct * tsec = selinux_cred(cred); + if (!tsec) { + return false; + } + struct lsm_context ctx; + bool result; + int err = __security_secid_to_secctx(tsec->sid, &ctx); + if (err) { + return false; + } + result = strncmp(context, ctx.context, ctx.len) == 0; + __security_release_secctx(&ctx); + return result; +} + +bool is_zygote(const struct cred* cred) +{ + return is_context(cred, "u:r:zygote:s0"); +} + +bool is_init(const struct cred* cred) { + return is_context(cred, "u:r:init:s0"); +} + +#define KSU_FILE_DOMAIN "u:object_r:ksu_file:s0" + +u32 ksu_get_ksu_file_sid() +{ + u32 ksu_file_sid = 0; + int err = security_secctx_to_secid(KSU_FILE_DOMAIN, strlen(KSU_FILE_DOMAIN), + &ksu_file_sid); + if (err) { + pr_info("get ksufile sid err %d\n", err); + } + return ksu_file_sid; +} diff --git a/kernel/selinux/selinux.h b/kernel/selinux/selinux.h new file mode 100644 index 0000000..431e044 --- /dev/null +++ b/kernel/selinux/selinux.h @@ -0,0 +1,28 @@ +#ifndef __KSU_H_SELINUX +#define __KSU_H_SELINUX + +#include "linux/types.h" +#include "linux/version.h" +#include "linux/cred.h" + +void setup_selinux(const char *); + +void setenforce(bool); + +bool getenforce(); + +bool is_task_ksu_domain(const struct cred* cred); + +bool is_ksu_domain(); + +bool is_zygote(const struct cred* cred); + +bool is_init(const struct cred* cred); + +void apply_kernelsu_rules(); + +u32 ksu_get_ksu_file_sid(); + +int handle_sepolicy(unsigned long arg3, void __user *arg4); + +#endif diff --git a/kernel/selinux/sepolicy.c b/kernel/selinux/sepolicy.c new file mode 100644 index 0000000..b8e0ec8 --- /dev/null +++ b/kernel/selinux/sepolicy.c @@ -0,0 +1,852 @@ +#include +#include +#include +#include + +#include "sepolicy.h" +#include "../klog.h" // IWYU pragma: keep +#include "ss/symtab.h" + +#define KSU_SUPPORT_ADD_TYPE + +////////////////////////////////////////////////////// +// Declaration +////////////////////////////////////////////////////// + +static struct avtab_node *get_avtab_node(struct policydb *db, + struct avtab_key *key, + struct avtab_extended_perms *xperms); + +static bool add_rule(struct policydb *db, const char *s, const char *t, + const char *c, const char *p, int effect, bool invert); + +static void add_rule_raw(struct policydb *db, struct type_datum *src, + struct type_datum *tgt, struct class_datum *cls, + struct perm_datum *perm, int effect, bool invert); + +static void add_xperm_rule_raw(struct policydb *db, struct type_datum *src, + struct type_datum *tgt, struct class_datum *cls, + uint16_t low, uint16_t high, int effect, + bool invert); +static bool add_xperm_rule(struct policydb *db, const char *s, const char *t, + const char *c, const char *range, int effect, + bool invert); + +static bool add_type_rule(struct policydb *db, const char *s, const char *t, + const char *c, const char *d, int effect); + +static bool add_filename_trans(struct policydb *db, const char *s, + const char *t, const char *c, const char *d, + const char *o); + +static bool add_genfscon(struct policydb *db, const char *fs_name, + const char *path, const char *context); + +static bool add_type(struct policydb *db, const char *type_name, bool attr); + +static bool set_type_state(struct policydb *db, const char *type_name, + bool permissive); + +static void add_typeattribute_raw(struct policydb *db, struct type_datum *type, + struct type_datum *attr); + +static bool add_typeattribute(struct policydb *db, const char *type, + const char *attr); + +////////////////////////////////////////////////////// +// Implementation +////////////////////////////////////////////////////// + +// Invert is adding rules for auditdeny; in other cases, invert is removing +// rules +#define strip_av(effect, invert) ((effect == AVTAB_AUDITDENY) == !invert) + +#define ksu_hash_for_each(node_ptr, n_slot, cur) \ + int i; \ + for (i = 0; i < n_slot; ++i) \ + for (cur = node_ptr[i]; cur; cur = cur->next) + +// htable is a struct instead of pointer above 5.8.0: +// https://elixir.bootlin.com/linux/v5.8-rc1/source/security/selinux/ss/symtab.h +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 8, 0) +#define ksu_hashtab_for_each(htab, cur) \ + ksu_hash_for_each(htab.htable, htab.size, cur) +#else +#define ksu_hashtab_for_each(htab, cur) \ + ksu_hash_for_each(htab->htable, htab->size, cur) +#endif + +// symtab_search is introduced on 5.9.0: +// https://elixir.bootlin.com/linux/v5.9-rc1/source/security/selinux/ss/symtab.h +#if LINUX_VERSION_CODE < KERNEL_VERSION(5, 9, 0) +#define symtab_search(s, name) hashtab_search((s)->table, name) +#define symtab_insert(s, name, datum) hashtab_insert((s)->table, name, datum) +#endif + +#define avtab_for_each(avtab, cur) \ + ksu_hash_for_each(avtab.htable, avtab.nslot, cur); + +static struct avtab_node *get_avtab_node(struct policydb *db, + struct avtab_key *key, + struct avtab_extended_perms *xperms) +{ + struct avtab_node *node; + + /* AVTAB_XPERMS entries are not necessarily unique */ + if (key->specified & AVTAB_XPERMS) { + bool match = false; + node = avtab_search_node(&db->te_avtab, key); + while (node) { + if ((node->datum.u.xperms->specified == + xperms->specified) && + (node->datum.u.xperms->driver == xperms->driver)) { + match = true; + break; + } + node = avtab_search_node_next(node, key->specified); + } + if (!match) + node = NULL; + } else { + node = avtab_search_node(&db->te_avtab, key); + } + + if (!node) { + struct avtab_datum avdatum = {}; + /* + * AUDITDENY, aka DONTAUDIT, are &= assigned, versus |= for + * others. Initialize the data accordingly. + */ + if (key->specified & AVTAB_XPERMS) { + avdatum.u.xperms = xperms; + } else { + avdatum.u.data = + key->specified == AVTAB_AUDITDENY ? ~0U : 0U; + } + /* this is used to get the node - insertion is actually unique */ + node = avtab_insert_nonunique(&db->te_avtab, key, &avdatum); + + int grow_size = sizeof(struct avtab_key); + grow_size += sizeof(struct avtab_datum); + if (key->specified & AVTAB_XPERMS) { + grow_size += sizeof(u8); + grow_size += sizeof(u8); + grow_size += sizeof(u32) * + ARRAY_SIZE(avdatum.u.xperms->perms.p); + } + db->len += grow_size; + } + + return node; +} + +static bool add_rule(struct policydb *db, const char *s, const char *t, + const char *c, const char *p, int effect, bool invert) +{ + struct type_datum *src = NULL, *tgt = NULL; + struct class_datum *cls = NULL; + struct perm_datum *perm = NULL; + + if (s) { + src = symtab_search(&db->p_types, s); + if (src == NULL) { + pr_info("source type %s does not exist\n", s); + return false; + } + } + + if (t) { + tgt = symtab_search(&db->p_types, t); + if (tgt == NULL) { + pr_info("target type %s does not exist\n", t); + return false; + } + } + + if (c) { + cls = symtab_search(&db->p_classes, c); + if (cls == NULL) { + pr_info("class %s does not exist\n", c); + return false; + } + } + + if (p) { + if (c == NULL) { + pr_info("No class is specified, cannot add perm [%s] \n", + p); + return false; + } + + perm = symtab_search(&cls->permissions, p); + if (perm == NULL && cls->comdatum != NULL) { + perm = symtab_search(&cls->comdatum->permissions, p); + } + if (perm == NULL) { + pr_info("perm %s does not exist in class %s\n", p, c); + return false; + } + } + add_rule_raw(db, src, tgt, cls, perm, effect, invert); + return true; +} + +static void add_rule_raw(struct policydb *db, struct type_datum *src, + struct type_datum *tgt, struct class_datum *cls, + struct perm_datum *perm, int effect, bool invert) +{ + if (src == NULL) { + struct hashtab_node *node; + if (strip_av(effect, invert)) { + ksu_hashtab_for_each(db->p_types.table, node) + { + add_rule_raw(db, + (struct type_datum *)node->datum, + tgt, cls, perm, effect, invert); + }; + } else { + ksu_hashtab_for_each(db->p_types.table, node) + { + struct type_datum *type = + (struct type_datum *)(node->datum); + if (type->attribute) { + add_rule_raw(db, type, tgt, cls, perm, + effect, invert); + } + }; + } + } else if (tgt == NULL) { + struct hashtab_node *node; + if (strip_av(effect, invert)) { + ksu_hashtab_for_each(db->p_types.table, node) + { + add_rule_raw(db, src, + (struct type_datum *)node->datum, + cls, perm, effect, invert); + }; + } else { + ksu_hashtab_for_each(db->p_types.table, node) + { + struct type_datum *type = + (struct type_datum *)(node->datum); + if (type->attribute) { + add_rule_raw(db, src, type, cls, perm, + effect, invert); + } + }; + } + } else if (cls == NULL) { + struct hashtab_node *node; + ksu_hashtab_for_each(db->p_classes.table, node) + { + add_rule_raw(db, src, tgt, + (struct class_datum *)node->datum, perm, + effect, invert); + } + } else { + struct avtab_key key; + key.source_type = src->value; + key.target_type = tgt->value; + key.target_class = cls->value; + key.specified = effect; + + struct avtab_node *node = get_avtab_node(db, &key, NULL); + if (invert) { + if (perm) + node->datum.u.data &= + ~(1U << (perm->value - 1)); + else + node->datum.u.data = 0U; + } else { + if (perm) + node->datum.u.data |= 1U << (perm->value - 1); + else + node->datum.u.data = ~0U; + } + } +} + +#define ioctl_driver(x) (x >> 8 & 0xFF) +#define ioctl_func(x) (x & 0xFF) + +#define xperm_test(x, p) (1 & (p[x >> 5] >> (x & 0x1f))) +#define xperm_set(x, p) (p[x >> 5] |= (1 << (x & 0x1f))) +#define xperm_clear(x, p) (p[x >> 5] &= ~(1 << (x & 0x1f))) + +static void add_xperm_rule_raw(struct policydb *db, struct type_datum *src, + struct type_datum *tgt, struct class_datum *cls, + uint16_t low, uint16_t high, int effect, + bool invert) +{ + if (src == NULL) { + struct hashtab_node *node; + ksu_hashtab_for_each(db->p_types.table, node) + { + struct type_datum *type = + (struct type_datum *)(node->datum); + if (type->attribute) { + add_xperm_rule_raw(db, type, tgt, cls, low, + high, effect, invert); + } + }; + } else if (tgt == NULL) { + struct hashtab_node *node; + ksu_hashtab_for_each(db->p_types.table, node) + { + struct type_datum *type = + (struct type_datum *)(node->datum); + if (type->attribute) { + add_xperm_rule_raw(db, src, type, cls, low, + high, effect, invert); + } + }; + } else if (cls == NULL) { + struct hashtab_node *node; + ksu_hashtab_for_each(db->p_classes.table, node) + { + add_xperm_rule_raw(db, src, tgt, + (struct class_datum *)(node->datum), + low, high, effect, invert); + }; + } else { + struct avtab_key key; + key.source_type = src->value; + key.target_type = tgt->value; + key.target_class = cls->value; + key.specified = effect; + + struct avtab_datum *datum; + struct avtab_node *node; + struct avtab_extended_perms xperms; + + memset(&xperms, 0, sizeof(xperms)); + if (ioctl_driver(low) != ioctl_driver(high)) { + xperms.specified = AVTAB_XPERMS_IOCTLDRIVER; + xperms.driver = 0; + } else { + xperms.specified = AVTAB_XPERMS_IOCTLFUNCTION; + xperms.driver = ioctl_driver(low); + } + int i; + if (xperms.specified == AVTAB_XPERMS_IOCTLDRIVER) { + for (i = ioctl_driver(low); i <= ioctl_driver(high); + ++i) { + if (invert) + xperm_clear(i, xperms.perms.p); + else + xperm_set(i, xperms.perms.p); + } + } else { + for (i = ioctl_func(low); i <= ioctl_func(high); ++i) { + if (invert) + xperm_clear(i, xperms.perms.p); + else + xperm_set(i, xperms.perms.p); + } + } + + node = get_avtab_node(db, &key, &xperms); + if (!node) { + pr_warn("add_xperm_rule_raw cannot found node!\n"); + return; + } + datum = &node->datum; + + if (datum->u.xperms == NULL) { + datum->u.xperms = + (struct avtab_extended_perms *)(kzalloc( + sizeof(xperms), GFP_KERNEL)); + if (!datum->u.xperms) { + pr_err("alloc xperms failed\n"); + return; + } + memcpy(datum->u.xperms, &xperms, sizeof(xperms)); + } + } +} + +static bool add_xperm_rule(struct policydb *db, const char *s, const char *t, + const char *c, const char *range, int effect, + bool invert) +{ + struct type_datum *src = NULL, *tgt = NULL; + struct class_datum *cls = NULL; + + if (s) { + src = symtab_search(&db->p_types, s); + if (src == NULL) { + pr_info("source type %s does not exist\n", s); + return false; + } + } + + if (t) { + tgt = symtab_search(&db->p_types, t); + if (tgt == NULL) { + pr_info("target type %s does not exist\n", t); + return false; + } + } + + if (c) { + cls = symtab_search(&db->p_classes, c); + if (cls == NULL) { + pr_info("class %s does not exist\n", c); + return false; + } + } + + u16 low, high; + + if (range) { + if (strchr(range, '-')) { + sscanf(range, "%hx-%hx", &low, &high); + } else { + sscanf(range, "%hx", &low); + high = low; + } + } else { + low = 0; + high = 0xFFFF; + } + + add_xperm_rule_raw(db, src, tgt, cls, low, high, effect, invert); + return true; +} + +static bool add_type_rule(struct policydb *db, const char *s, const char *t, + const char *c, const char *d, int effect) +{ + struct type_datum *src, *tgt, *def; + struct class_datum *cls; + + src = symtab_search(&db->p_types, s); + if (src == NULL) { + pr_info("source type %s does not exist\n", s); + return false; + } + tgt = symtab_search(&db->p_types, t); + if (tgt == NULL) { + pr_info("target type %s does not exist\n", t); + return false; + } + cls = symtab_search(&db->p_classes, c); + if (cls == NULL) { + pr_info("class %s does not exist\n", c); + return false; + } + def = symtab_search(&db->p_types, d); + if (def == NULL) { + pr_info("default type %s does not exist\n", d); + return false; + } + + struct avtab_key key; + key.source_type = src->value; + key.target_type = tgt->value; + key.target_class = cls->value; + key.specified = effect; + + struct avtab_node *node = get_avtab_node(db, &key, NULL); + node->datum.u.data = def->value; + + return true; +} + +// 5.9.0 : static inline int hashtab_insert(struct hashtab *h, void *key, void +// *datum, struct hashtab_key_params key_params) 5.8.0: int +// hashtab_insert(struct hashtab *h, void *k, void *d); +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 9, 0) +static u32 filenametr_hash(const void *k) +{ + const struct filename_trans_key *ft = k; + unsigned long hash; + unsigned int byte_num; + unsigned char focus; + + hash = ft->ttype ^ ft->tclass; + + byte_num = 0; + while ((focus = ft->name[byte_num++])) + hash = partial_name_hash(focus, hash); + return hash; +} + +static int filenametr_cmp(const void *k1, const void *k2) +{ + const struct filename_trans_key *ft1 = k1; + const struct filename_trans_key *ft2 = k2; + int v; + + v = ft1->ttype - ft2->ttype; + if (v) + return v; + + v = ft1->tclass - ft2->tclass; + if (v) + return v; + + return strcmp(ft1->name, ft2->name); +} + +static const struct hashtab_key_params filenametr_key_params = { + .hash = filenametr_hash, + .cmp = filenametr_cmp, +}; +#endif + +static bool add_filename_trans(struct policydb *db, const char *s, + const char *t, const char *c, const char *d, + const char *o) +{ + struct type_datum *src, *tgt, *def; + struct class_datum *cls; + + src = symtab_search(&db->p_types, s); + if (src == NULL) { + pr_warn("source type %s does not exist\n", s); + return false; + } + tgt = symtab_search(&db->p_types, t); + if (tgt == NULL) { + pr_warn("target type %s does not exist\n", t); + return false; + } + cls = symtab_search(&db->p_classes, c); + if (cls == NULL) { + pr_warn("class %s does not exist\n", c); + return false; + } + def = symtab_search(&db->p_types, d); + if (def == NULL) { + pr_warn("default type %s does not exist\n", d); + return false; + } + + struct filename_trans_key key; + key.ttype = tgt->value; + key.tclass = cls->value; + key.name = (char *)o; + + struct filename_trans_datum *last = NULL; + + struct filename_trans_datum *trans = + policydb_filenametr_search(db, &key); + while (trans) { + if (ebitmap_get_bit(&trans->stypes, src->value - 1)) { + // Duplicate, overwrite existing data and return + trans->otype = def->value; + return true; + } + if (trans->otype == def->value) + break; + last = trans; + trans = trans->next; + } + + if (trans == NULL) { + trans = (struct filename_trans_datum *)kcalloc(1 ,sizeof(*trans), + GFP_ATOMIC); + struct filename_trans_key *new_key = + (struct filename_trans_key *)kzalloc(sizeof(*new_key), + GFP_ATOMIC); + *new_key = key; + new_key->name = kstrdup(key.name, GFP_ATOMIC); + trans->next = last; + trans->otype = def->value; + hashtab_insert(&db->filename_trans, new_key, trans, + filenametr_key_params); + } + + db->compat_filename_trans_count++; + return ebitmap_set_bit(&trans->stypes, src->value - 1, 1) == 0; +} + +static bool add_genfscon(struct policydb *db, const char *fs_name, + const char *path, const char *context) +{ + return false; +} + +static void *ksu_realloc(void *old, size_t new_size, size_t old_size) +{ + // we can't use krealloc, because it may be read-only + void *new = kzalloc(new_size, GFP_ATOMIC); + if (!new) { + return NULL; + } + if (old_size) { + memcpy(new, old, old_size); + } + // we can't use kfree, because it may be read-only + // there maybe some leaks, maybe we can check ptr_write, but it's not a big deal + // kfree(old); + return new; +} + +static bool add_type(struct policydb *db, const char *type_name, bool attr) +{ + struct type_datum *type = symtab_search(&db->p_types, type_name); + if (type) { + pr_warn("Type %s already exists\n", type_name); + return true; + } + + u32 value = ++db->p_types.nprim; + type = (struct type_datum *)kzalloc(sizeof(struct type_datum), + GFP_ATOMIC); + if (!type) { + pr_err("add_type: alloc type_datum failed.\n"); + return false; + } + + type->primary = 1; + type->value = value; + type->attribute = attr; + + char *key = kstrdup(type_name, GFP_ATOMIC); + if (!key) { + pr_err("add_type: alloc key failed.\n"); + return false; + } + + if (symtab_insert(&db->p_types, key, type)) { + pr_err("add_type: insert symtab failed.\n"); + return false; + } + + struct ebitmap *new_type_attr_map_array = + ksu_realloc(db->type_attr_map_array, + value * sizeof(struct ebitmap), + (value - 1) * sizeof(struct ebitmap)); + + if (!new_type_attr_map_array) { + pr_err("add_type: alloc type_attr_map_array failed\n"); + return false; + } + + struct type_datum **new_type_val_to_struct = + ksu_realloc(db->type_val_to_struct, + sizeof(*db->type_val_to_struct) * value, + sizeof(*db->type_val_to_struct) * (value - 1)); + + if (!new_type_val_to_struct) { + pr_err("add_type: alloc type_val_to_struct failed\n"); + return false; + } + + char **new_val_to_name_types = + ksu_realloc(db->sym_val_to_name[SYM_TYPES], + sizeof(char *) * value, + sizeof(char *) * (value - 1)); + if (!new_val_to_name_types) { + pr_err("add_type: alloc val_to_name failed\n"); + return false; + } + + db->type_attr_map_array = new_type_attr_map_array; + ebitmap_init(&db->type_attr_map_array[value - 1]); + ebitmap_set_bit(&db->type_attr_map_array[value - 1], value - 1, 1); + + db->type_val_to_struct = new_type_val_to_struct; + db->type_val_to_struct[value - 1] = type; + + db->sym_val_to_name[SYM_TYPES] = new_val_to_name_types; + db->sym_val_to_name[SYM_TYPES][value - 1] = key; + + int i; + for (i = 0; i < db->p_roles.nprim; ++i) { + ebitmap_set_bit(&db->role_val_to_struct[i]->types, value - 1, + 1); + } + + return true; +} + +static bool set_type_state(struct policydb *db, const char *type_name, + bool permissive) +{ + struct type_datum *type; + if (type_name == NULL) { + struct hashtab_node *node; + ksu_hashtab_for_each(db->p_types.table, node) + { + type = (struct type_datum *)(node->datum); + if (ebitmap_set_bit(&db->permissive_map, type->value, + permissive)) + pr_info("Could not set bit in permissive map\n"); + }; + } else { + type = (struct type_datum *)symtab_search(&db->p_types, + type_name); + if (type == NULL) { + pr_info("type %s does not exist\n", type_name); + return false; + } + if (ebitmap_set_bit(&db->permissive_map, type->value, + permissive)) { + pr_info("Could not set bit in permissive map\n"); + return false; + } + } + return true; +} + +static void add_typeattribute_raw(struct policydb *db, struct type_datum *type, + struct type_datum *attr) +{ + struct ebitmap *sattr = &db->type_attr_map_array[type->value - 1]; + ebitmap_set_bit(sattr, attr->value - 1, 1); + + struct hashtab_node *node; + struct constraint_node *n; + struct constraint_expr *e; + ksu_hashtab_for_each(db->p_classes.table, node) + { + struct class_datum *cls = (struct class_datum *)(node->datum); + for (n = cls->constraints; n; n = n->next) { + for (e = n->expr; e; e = e->next) { + if (e->expr_type == CEXPR_NAMES && + ebitmap_get_bit(&e->type_names->types, + attr->value - 1)) { + ebitmap_set_bit(&e->names, + type->value - 1, 1); + } + } + } + }; +} + +static bool add_typeattribute(struct policydb *db, const char *type, + const char *attr) +{ + struct type_datum *type_d = symtab_search(&db->p_types, type); + if (type_d == NULL) { + pr_info("type %s does not exist\n", type); + return false; + } else if (type_d->attribute) { + pr_info("type %s is an attribute\n", attr); + return false; + } + + struct type_datum *attr_d = symtab_search(&db->p_types, attr); + if (attr_d == NULL) { + pr_info("attribute %s does not exist\n", type); + return false; + } else if (!attr_d->attribute) { + pr_info("type %s is not an attribute \n", attr); + return false; + } + + add_typeattribute_raw(db, type_d, attr_d); + return true; +} + +////////////////////////////////////////////////////////////////////////// + +// Operation on types +bool ksu_type(struct policydb *db, const char *name, const char *attr) +{ + return add_type(db, name, false) && add_typeattribute(db, name, attr); +} + +bool ksu_attribute(struct policydb *db, const char *name) +{ + return add_type(db, name, true); +} + +bool ksu_permissive(struct policydb *db, const char *type) +{ + return set_type_state(db, type, true); +} + +bool ksu_enforce(struct policydb *db, const char *type) +{ + return set_type_state(db, type, false); +} + +bool ksu_typeattribute(struct policydb *db, const char *type, const char *attr) +{ + return add_typeattribute(db, type, attr); +} + +bool ksu_exists(struct policydb *db, const char *type) +{ + return symtab_search(&db->p_types, type) != NULL; +} + +// Access vector rules +bool ksu_allow(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm) +{ + return add_rule(db, src, tgt, cls, perm, AVTAB_ALLOWED, false); +} + +bool ksu_deny(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm) +{ + return add_rule(db, src, tgt, cls, perm, AVTAB_ALLOWED, true); +} + +bool ksu_auditallow(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm) +{ + return add_rule(db, src, tgt, cls, perm, AVTAB_AUDITALLOW, false); +} +bool ksu_dontaudit(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm) +{ + return add_rule(db, src, tgt, cls, perm, AVTAB_AUDITDENY, true); +} + +// Extended permissions access vector rules +bool ksu_allowxperm(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *range) +{ + return add_xperm_rule(db, src, tgt, cls, range, AVTAB_XPERMS_ALLOWED, + false); +} + +bool ksu_auditallowxperm(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *range) +{ + return add_xperm_rule(db, src, tgt, cls, range, AVTAB_XPERMS_AUDITALLOW, + false); +} + +bool ksu_dontauditxperm(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *range) +{ + return add_xperm_rule(db, src, tgt, cls, range, AVTAB_XPERMS_DONTAUDIT, + false); +} + +// Type rules +bool ksu_type_transition(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *def, const char *obj) +{ + if (obj) { + return add_filename_trans(db, src, tgt, cls, def, obj); + } else { + return add_type_rule(db, src, tgt, cls, def, AVTAB_TRANSITION); + } +} + +bool ksu_type_change(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *def) +{ + return add_type_rule(db, src, tgt, cls, def, AVTAB_CHANGE); +} + +bool ksu_type_member(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *def) +{ + return add_type_rule(db, src, tgt, cls, def, AVTAB_MEMBER); +} + +// File system labeling +bool ksu_genfscon(struct policydb *db, const char *fs_name, const char *path, + const char *ctx) +{ + return add_genfscon(db, fs_name, path, ctx); +} diff --git a/kernel/selinux/sepolicy.h b/kernel/selinux/sepolicy.h new file mode 100644 index 0000000..fd062ce --- /dev/null +++ b/kernel/selinux/sepolicy.h @@ -0,0 +1,46 @@ +#ifndef __KSU_H_SEPOLICY +#define __KSU_H_SEPOLICY + +#include + +#include "ss/policydb.h" + +// Operation on types +bool ksu_type(struct policydb *db, const char *name, const char *attr); +bool ksu_attribute(struct policydb *db, const char *name); +bool ksu_permissive(struct policydb *db, const char *type); +bool ksu_enforce(struct policydb *db, const char *type); +bool ksu_typeattribute(struct policydb *db, const char *type, const char *attr); +bool ksu_exists(struct policydb *db, const char *type); + +// Access vector rules +bool ksu_allow(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm); +bool ksu_deny(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm); +bool ksu_auditallow(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm); +bool ksu_dontaudit(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *perm); + +// Extended permissions access vector rules +bool ksu_allowxperm(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *range); +bool ksu_auditallowxperm(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *range); +bool ksu_dontauditxperm(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *range); + +// Type rules +bool ksu_type_transition(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *def, const char *obj); +bool ksu_type_change(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *def); +bool ksu_type_member(struct policydb *db, const char *src, const char *tgt, + const char *cls, const char *def); + +// File system labeling +bool ksu_genfscon(struct policydb *db, const char *fs_name, const char *path, + const char *ctx); + +#endif diff --git a/kernel/setuid_hook.c b/kernel/setuid_hook.c new file mode 100644 index 0000000..44dffd9 --- /dev/null +++ b/kernel/setuid_hook.c @@ -0,0 +1,171 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "allowlist.h" +#include "setuid_hook.h" +#include "feature.h" +#include "klog.h" // IWYU pragma: keep +#include "manager.h" +#include "selinux/selinux.h" +#include "seccomp_cache.h" +#include "supercalls.h" +#include "syscall_hook_manager.h" +#include "kernel_umount.h" +#include "app_profile.h" + +static bool ksu_enhanced_security_enabled = false; + +static int enhanced_security_feature_get(u64 *value) +{ + *value = ksu_enhanced_security_enabled ? 1 : 0; + return 0; +} + +static int enhanced_security_feature_set(u64 value) +{ + bool enable = value != 0; + ksu_enhanced_security_enabled = enable; + pr_info("enhanced_security: set to %d\n", enable); + return 0; +} + +static const struct ksu_feature_handler enhanced_security_handler = { + .feature_id = KSU_FEATURE_ENHANCED_SECURITY, + .name = "enhanced_security", + .get_handler = enhanced_security_feature_get, + .set_handler = enhanced_security_feature_set, +}; + +static inline bool is_allow_su() +{ + if (is_manager()) { + // we are manager, allow! + return true; + } + return ksu_is_allow_uid_for_current(current_uid().val); +} + +int ksu_handle_setresuid(uid_t ruid, uid_t euid, uid_t suid) +{ + uid_t new_uid = ruid; + uid_t old_uid = current_uid().val; + + pr_info("handle_setresuid from %d to %d\n", old_uid, new_uid); + + // if old process is root, ignore it. + if (old_uid != 0 && ksu_enhanced_security_enabled) { + // disallow any non-ksu domain escalation from non-root to root! + // euid is what we care about here as it controls permission + if (unlikely(euid == 0)) { + if (!is_ksu_domain()) { + pr_warn("find suspicious EoP: %d %s, from %d to %d\n", + current->pid, current->comm, old_uid, new_uid); + force_sig(SIGKILL); + return 0; + } + } + // disallow appuid decrease to any other uid if it is not allowed to su + if (is_appuid(old_uid)) { + if (euid < current_euid().val && !ksu_is_allow_uid_for_current(old_uid)) { + pr_warn("find suspicious EoP: %d %s, from %d to %d\n", + current->pid, current->comm, old_uid, new_uid); + force_sig(SIGKILL); + return 0; + } + } + return 0; + } + + // if on private space, see if its possibly the manager + if (new_uid > PER_USER_RANGE && new_uid % PER_USER_RANGE == ksu_get_manager_uid()) { + ksu_set_manager_uid(new_uid); + } + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 10, 0) + if (ksu_get_manager_uid() == new_uid) { + pr_info("install fd for manager: %d\n", new_uid); + ksu_install_fd(); + spin_lock_irq(¤t->sighand->siglock); + ksu_seccomp_allow_cache(current->seccomp.filter, __NR_reboot); + ksu_set_task_tracepoint_flag(current); + spin_unlock_irq(¤t->sighand->siglock); + return 0; + } + + if (ksu_is_allow_uid_for_current(new_uid)) { + if (current->seccomp.mode == SECCOMP_MODE_FILTER && + current->seccomp.filter) { + spin_lock_irq(¤t->sighand->siglock); + ksu_seccomp_allow_cache(current->seccomp.filter, __NR_reboot); + spin_unlock_irq(¤t->sighand->siglock); + } + ksu_set_task_tracepoint_flag(current); + } else { + ksu_clear_task_tracepoint_flag_if_needed(current); + } +#else + if (ksu_is_allow_uid_for_current(new_uid)) { + spin_lock_irq(¤t->sighand->siglock); + disable_seccomp(); + spin_unlock_irq(¤t->sighand->siglock); + + if (ksu_get_manager_uid() == new_uid) { + pr_info("install fd for ksu manager(uid=%d)\n", + new_uid); + ksu_install_fd(); + } + + return 0; + } +#endif + + // Handle kernel umount + ksu_handle_umount(old_uid, new_uid); + + return 0; +} + +void ksu_setuid_hook_init(void) +{ + ksu_kernel_umount_init(); + if (ksu_register_feature_handler(&enhanced_security_handler)) { + pr_err("Failed to register enhanced security feature handler\n"); + } +} + +void ksu_setuid_hook_exit(void) +{ + pr_info("ksu_core_exit\n"); + ksu_kernel_umount_exit(); + ksu_unregister_feature_handler(KSU_FEATURE_ENHANCED_SECURITY); +} diff --git a/kernel/setuid_hook.h b/kernel/setuid_hook.h new file mode 100644 index 0000000..fc5b93a --- /dev/null +++ b/kernel/setuid_hook.h @@ -0,0 +1,14 @@ +#ifndef __KSU_H_KSU_CORE +#define __KSU_H_KSU_CORE + +#include +#include +#include "apk_sign.h" +#include + +void ksu_setuid_hook_init(void); +void ksu_setuid_hook_exit(void); + +int ksu_handle_setresuid(uid_t ruid, uid_t euid, uid_t suid); + +#endif diff --git a/kernel/setup.sh b/kernel/setup.sh new file mode 100755 index 0000000..fe1c672 --- /dev/null +++ b/kernel/setup.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +KERNEL_ROOT=$(pwd) + +display_usage() { + echo "Usage: $0 [--cleanup | ]" + echo " --cleanup: Cleans up previous modifications made by the script." + echo " : Sets up or updates the KernelSU to specified tag or commit." + echo " -h, --help: Displays this usage information." + echo " (no args): Sets up or updates the KernelSU environment to the latest tagged version." +} + +initialize_variables() { + if test -d "$KERNEL_ROOT/common/drivers"; then + DRIVER_DIR="$KERNEL_ROOT/common/drivers" + elif test -d "$KERNEL_ROOT/drivers"; then + DRIVER_DIR="$KERNEL_ROOT/drivers" + else + echo '[ERROR] "drivers/" directory not found.' + exit 127 + fi + + DRIVER_MAKEFILE=$DRIVER_DIR/Makefile + DRIVER_KCONFIG=$DRIVER_DIR/Kconfig +} + +# Reverts modifications made by this script +perform_cleanup() { + echo "[+] Cleaning up..." + [ -L "$DRIVER_DIR/kernelsu" ] && rm "$DRIVER_DIR/kernelsu" && echo "[-] Symlink removed." + grep -q "kernelsu" "$DRIVER_MAKEFILE" && sed -i '/kernelsu/d' "$DRIVER_MAKEFILE" && echo "[-] Makefile reverted." + grep -q "kernelsu" "$DRIVER_KCONFIG" && sed -i '/kernelsu/d' "$DRIVER_KCONFIG" && echo "[-] Kconfig reverted." + if [ -d "$KERNEL_ROOT/KernelSU" ]; then + rm -rf "$KERNEL_ROOT/KernelSU" && echo "[-] KernelSU directory deleted." + fi +} + +# Sets up or update KernelSU environment +setup_kernelsu() { + echo "[+] Setting up KernelSU..." + # Clone the repository + if [ ! -d "$KERNEL_ROOT/KernelSU" ]; then + git clone https://github.com/SukiSU-Ultra/SukiSU-Ultra KernelSU + echo "[+] Repository cloned." + fi + cd "$KERNEL_ROOT/KernelSU" + git stash && echo "[-] Stashed current changes." + if [ "$(git status | grep -Po 'v\d+(\.\d+)*' | head -n1)" ]; then + git checkout main && echo "[-] Switched to main branch." + fi + git pull && echo "[+] Repository updated." + if [ -z "${1-}" ]; then + git checkout "$(git describe --abbrev=0 --tags)" && echo "[-] Checked out latest tag." + else + git checkout "$1" && echo "[-] Checked out $1." || echo "[-] Checkout default branch" + fi + cd "$DRIVER_DIR" + ln -sf "$(realpath --relative-to="$DRIVER_DIR" "$KERNEL_ROOT/KernelSU/kernel")" "kernelsu" && echo "[+] Symlink created." + + # Add entries in Makefile and Kconfig if not already existing + grep -q "kernelsu" "$DRIVER_MAKEFILE" || echo 'obj-$(CONFIG_KSU) += kernelsu/' >> "$DRIVER_MAKEFILE" && echo "[+] Modified Makefile." + grep -q 'source "drivers/kernelsu/Kconfig"' "$DRIVER_KCONFIG" || sed -i '/endmenu/i\source "drivers/kernelsu/Kconfig"' "$DRIVER_KCONFIG" && echo "[+] Modified Kconfig." + echo '[+] Done.' +} + +# Process command-line arguments +if [ "$#" -eq 0 ]; then + initialize_variables + setup_kernelsu +elif [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + display_usage +elif [ "$1" = "--cleanup" ]; then + initialize_variables + perform_cleanup +else + initialize_variables + setup_kernelsu "$@" +fi diff --git a/kernel/sucompat.c b/kernel/sucompat.c new file mode 100644 index 0000000..f31e319 --- /dev/null +++ b/kernel/sucompat.c @@ -0,0 +1,187 @@ +#include "linux/compiler.h" +#include "linux/printk.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#include "allowlist.h" +#include "feature.h" +#include "klog.h" // IWYU pragma: keep +#include "ksud.h" +#include "sucompat.h" +#include "app_profile.h" +#include "syscall_hook_manager.h" + +#include "sulog.h" + +#define SU_PATH "/system/bin/su" +#define SH_PATH "/system/bin/sh" + +bool ksu_su_compat_enabled __read_mostly = true; + +static int su_compat_feature_get(u64 *value) +{ + *value = ksu_su_compat_enabled ? 1 : 0; + return 0; +} + +static int su_compat_feature_set(u64 value) +{ + bool enable = value != 0; + ksu_su_compat_enabled = enable; + pr_info("su_compat: set to %d\n", enable); + return 0; +} + +static const struct ksu_feature_handler su_compat_handler = { + .feature_id = KSU_FEATURE_SU_COMPAT, + .name = "su_compat", + .get_handler = su_compat_feature_get, + .set_handler = su_compat_feature_set, +}; + +static void __user *userspace_stack_buffer(const void *d, size_t len) +{ + // To avoid having to mmap a page in userspace, just write below the stack + // pointer. + char __user *p = (void __user *)current_user_stack_pointer() - len; + + return copy_to_user(p, d, len) ? NULL : p; +} + +static char __user *sh_user_path(void) +{ + static const char sh_path[] = "/system/bin/sh"; + + return userspace_stack_buffer(sh_path, sizeof(sh_path)); +} + +static char __user *ksud_user_path(void) +{ + static const char ksud_path[] = KSUD_PATH; + + return userspace_stack_buffer(ksud_path, sizeof(ksud_path)); +} + +int ksu_handle_faccessat(int *dfd, const char __user **filename_user, int *mode, + int *__unused_flags) +{ + const char su[] = SU_PATH; + + if (!ksu_is_allow_uid_for_current(current_uid().val)) { + return 0; + } + + char path[sizeof(su) + 1]; + memset(path, 0, sizeof(path)); + strncpy_from_user_nofault(path, *filename_user, sizeof(path)); + + if (unlikely(!memcmp(path, su, sizeof(su)))) { +#if __SULOG_GATE + ksu_sulog_report_syscall(current_uid().val, NULL, "faccessat", path); +#endif + pr_info("faccessat su->sh!\n"); + *filename_user = sh_user_path(); + } + + return 0; +} + +int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags) +{ + // const char sh[] = SH_PATH; + const char su[] = SU_PATH; + + if (!ksu_is_allow_uid_for_current(current_uid().val)) { + return 0; + } + + if (unlikely(!filename_user)) { + return 0; + } + + char path[sizeof(su) + 1]; + memset(path, 0, sizeof(path)); +// Remove this later!! we use syscall hook, so this will never happen!!!!! +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 18, 0) && 0 + // it becomes a `struct filename *` after 5.18 + // https://elixir.bootlin.com/linux/v5.18/source/fs/stat.c#L216 + const char sh[] = SH_PATH; + struct filename *filename = *((struct filename **)filename_user); + if (IS_ERR(filename)) { + return 0; + } + if (likely(memcmp(filename->name, su, sizeof(su)))) + return 0; + pr_info("vfs_statx su->sh!\n"); + memcpy((void *)filename->name, sh, sizeof(sh)); +#else + strncpy_from_user_nofault(path, *filename_user, sizeof(path)); + + if (unlikely(!memcmp(path, su, sizeof(su)))) { +#if __SULOG_GATE + ksu_sulog_report_syscall(current_uid().val, NULL, "newfstatat", path); +#endif + pr_info("newfstatat su->sh!\n"); + *filename_user = sh_user_path(); + } +#endif + + return 0; +} + +int ksu_handle_execve_sucompat(const char __user **filename_user, + void *__never_use_argv, void *__never_use_envp, + int *__never_use_flags) +{ + const char su[] = SU_PATH; + char path[sizeof(su) + 1]; + + if (unlikely(!filename_user)) + return 0; + + memset(path, 0, sizeof(path)); + strncpy_from_user_nofault(path, *filename_user, sizeof(path)); + + if (likely(memcmp(path, su, sizeof(su)))) + return 0; + +#if __SULOG_GATE + bool is_allowed = ksu_is_allow_uid_for_current(current_uid().val); + ksu_sulog_report_syscall(current_uid().val, NULL, "execve", path); + + if (!is_allowed) + return 0; + + ksu_sulog_report_su_attempt(current_uid().val, NULL, path, is_allowed); +#else + if (!ksu_is_allow_uid_for_current(current_uid().val)) { + return 0; + } +#endif + + pr_info("sys_execve su found\n"); + *filename_user = ksud_user_path(); + + escape_with_root_profile(); + + return 0; +} + +// sucompat: permitted process can execute 'su' to gain root access. +void ksu_sucompat_init() +{ + if (ksu_register_feature_handler(&su_compat_handler)) { + pr_err("Failed to register su_compat feature handler\n"); + } +} + +void ksu_sucompat_exit() +{ + ksu_unregister_feature_handler(KSU_FEATURE_SU_COMPAT); +} \ No newline at end of file diff --git a/kernel/sucompat.h b/kernel/sucompat.h new file mode 100644 index 0000000..82161f7 --- /dev/null +++ b/kernel/sucompat.h @@ -0,0 +1,18 @@ +#ifndef __KSU_H_SUCOMPAT +#define __KSU_H_SUCOMPAT +#include + +extern bool ksu_su_compat_enabled; + +void ksu_sucompat_init(void); +void ksu_sucompat_exit(void); + +// Handler functions exported for hook_manager +int ksu_handle_faccessat(int *dfd, const char __user **filename_user, + int *mode, int *__unused_flags); +int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags); +int ksu_handle_execve_sucompat(const char __user **filename_user, + void *__never_use_argv, void *__never_use_envp, + int *__never_use_flags); + +#endif \ No newline at end of file diff --git a/kernel/sulog.c b/kernel/sulog.c new file mode 100644 index 0000000..84993d2 --- /dev/null +++ b/kernel/sulog.c @@ -0,0 +1,369 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "klog.h" + +#include "sulog.h" +#include "ksu.h" +#include "feature.h" + +#if __SULOG_GATE + +struct dedup_entry dedup_tbl[SULOG_COMM_LEN]; +static DEFINE_SPINLOCK(dedup_lock); +static LIST_HEAD(sulog_queue); +static struct workqueue_struct *sulog_workqueue; +static struct work_struct sulog_work; +static bool sulog_enabled __read_mostly = true; + +static int sulog_feature_get(u64 *value) +{ + *value = sulog_enabled ? 1 : 0; + return 0; +} + +static int sulog_feature_set(u64 value) +{ + bool enable = value != 0; + sulog_enabled = enable; + pr_info("sulog: set to %d\n", enable); + return 0; +} + +static const struct ksu_feature_handler sulog_handler = { + .feature_id = KSU_FEATURE_SULOG, + .name = "sulog", + .get_handler = sulog_feature_get, + .set_handler = sulog_feature_set, +}; + +static void get_timestamp(char *buf, size_t len) +{ + struct timespec64 ts; + struct tm tm; + + ktime_get_real_ts64(&ts); + time64_to_tm(ts.tv_sec - sys_tz.tz_minuteswest * 60, 0, &tm); + + snprintf(buf, len, "%04ld-%02d-%02d %02d:%02d:%02d", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); +} + +static void ksu_get_cmdline(char *full_comm, const char *comm, size_t buf_len) +{ + if (!full_comm || buf_len <= 0) + return; + + if (comm && strlen(comm) > 0) { + KSU_STRSCPY(full_comm, comm, buf_len); + return; + } + + if (in_atomic() || in_interrupt() || irqs_disabled()) { + KSU_STRSCPY(full_comm, current->comm, buf_len); + return; + } + + if (!current->mm) { + KSU_STRSCPY(full_comm, current->comm, buf_len); + return; + } + + int n = get_cmdline(current, full_comm, buf_len); + if (n <= 0) { + KSU_STRSCPY(full_comm, current->comm, buf_len); + return; + } + + for (int i = 0; i < n && i < buf_len - 1; i++) { + if (full_comm[i] == '\0') + full_comm[i] = ' '; + } + full_comm[n < buf_len ? n : buf_len - 1] = '\0'; +} + +static void sanitize_string(char *str, size_t len) +{ + if (!str || len == 0) + return; + + size_t read_pos = 0, write_pos = 0; + + while (read_pos < len && str[read_pos] != '\0') { + char c = str[read_pos]; + + if (c == '\n' || c == '\r') { + read_pos++; + continue; + } + + if (c == ' ' && write_pos > 0 && str[write_pos - 1] == ' ') { + read_pos++; + continue; + } + + str[write_pos++] = c; + read_pos++; + } + + str[write_pos] = '\0'; +} + +static bool dedup_should_print(uid_t uid, u8 type, const char *content, size_t len) +{ + struct dedup_key key = { + .crc = dedup_calc_hash(content, len), + .uid = uid, + .type = type, + }; + u64 now = ktime_get_ns(); + u64 delta_ns = DEDUP_SECS * NSEC_PER_SEC; + + u32 idx = key.crc & (SULOG_COMM_LEN - 1); + spin_lock(&dedup_lock); + + struct dedup_entry *e = &dedup_tbl[idx]; + if (e->key.crc == key.crc && + e->key.uid == key.uid && + e->key.type == key.type && + (now - e->ts_ns) < delta_ns) { + spin_unlock(&dedup_lock); + return false; + } + + e->key = key; + e->ts_ns = now; + spin_unlock(&dedup_lock); + return true; +} + +static void sulog_work_handler(struct work_struct *work) +{ + struct file *fp; + struct sulog_entry *entry, *tmp; + LIST_HEAD(local_queue); + loff_t pos = 0; + unsigned long flags; + + spin_lock_irqsave(&dedup_lock, flags); + list_splice_init(&sulog_queue, &local_queue); + spin_unlock_irqrestore(&dedup_lock, flags); + + if (list_empty(&local_queue)) + return; + + fp = filp_open(SULOG_PATH, O_WRONLY | O_CREAT | O_APPEND, 0640); + if (IS_ERR(fp)) { + pr_err("sulog: failed to open log file: %ld\n", PTR_ERR(fp)); + goto cleanup; + } + + if (fp->f_inode->i_size > SULOG_MAX_SIZE) { + if (vfs_truncate(&fp->f_path, 0)) + pr_err("sulog: failed to truncate log file\n"); + pos = 0; + } else { + pos = fp->f_inode->i_size; + } + + list_for_each_entry(entry, &local_queue, list) + kernel_write(fp, entry->content, strlen(entry->content), &pos); + + vfs_fsync(fp, 0); + filp_close(fp, 0); + +cleanup: + list_for_each_entry_safe(entry, tmp, &local_queue, list) { + list_del(&entry->list); + kfree(entry); + } +} + +static void sulog_add_entry(char *log_buf, size_t len, uid_t uid, u8 dedup_type) +{ + struct sulog_entry *entry; + unsigned long flags; + + if (!sulog_enabled || !log_buf || len == 0) + return; + + if (!dedup_should_print(uid, dedup_type, log_buf, len)) + return; + + entry = kzalloc(sizeof(*entry), GFP_ATOMIC); + if (!entry) + return; + + KSU_STRSCPY(entry->content, log_buf, SULOG_ENTRY_MAX_LEN); + + spin_lock_irqsave(&dedup_lock, flags); + list_add_tail(&entry->list, &sulog_queue); + spin_unlock_irqrestore(&dedup_lock, flags); + + if (sulog_workqueue) + queue_work(sulog_workqueue, &sulog_work); +} + +void ksu_sulog_report_su_grant(uid_t uid, const char *comm, const char *method) +{ + char log_buf[SULOG_ENTRY_MAX_LEN]; + char timestamp[32]; + char full_comm[SULOG_COMM_LEN]; + + if (!sulog_enabled) + return; + + get_timestamp(timestamp, sizeof(timestamp)); + ksu_get_cmdline(full_comm, comm, sizeof(full_comm)); + + sanitize_string(full_comm, sizeof(full_comm)); + + snprintf(log_buf, sizeof(log_buf), + "[%s] SU_GRANT: UID=%d COMM=%s METHOD=%s PID=%d\n", + timestamp, uid, full_comm, method ? method : "unknown", current->pid); + + sulog_add_entry(log_buf, strlen(log_buf), uid, DEDUP_SU_GRANT); +} + +void ksu_sulog_report_su_attempt(uid_t uid, const char *comm, const char *target_path, bool success) +{ + char log_buf[SULOG_ENTRY_MAX_LEN]; + char timestamp[32]; + char full_comm[SULOG_COMM_LEN]; + + if (!sulog_enabled) + return; + + get_timestamp(timestamp, sizeof(timestamp)); + ksu_get_cmdline(full_comm, comm, sizeof(full_comm)); + + sanitize_string(full_comm, sizeof(full_comm)); + + snprintf(log_buf, sizeof(log_buf), + "[%s] SU_EXEC: UID=%d COMM=%s TARGET=%s RESULT=%s PID=%d\n", + timestamp, uid, full_comm, target_path ? target_path : "unknown", + success ? "SUCCESS" : "DENIED", current->pid); + + sulog_add_entry(log_buf, strlen(log_buf), uid, DEDUP_SU_ATTEMPT); +} + +void ksu_sulog_report_permission_check(uid_t uid, const char *comm, bool allowed) +{ + char log_buf[SULOG_ENTRY_MAX_LEN]; + char timestamp[32]; + char full_comm[SULOG_COMM_LEN]; + + if (!sulog_enabled) + return; + + get_timestamp(timestamp, sizeof(timestamp)); + ksu_get_cmdline(full_comm, comm, sizeof(full_comm)); + + sanitize_string(full_comm, sizeof(full_comm)); + + snprintf(log_buf, sizeof(log_buf), + "[%s] PERM_CHECK: UID=%d COMM=%s RESULT=%s PID=%d\n", + timestamp, uid, full_comm, allowed ? "ALLOWED" : "DENIED", current->pid); + + sulog_add_entry(log_buf, strlen(log_buf), uid, DEDUP_PERM_CHECK); +} + +void ksu_sulog_report_manager_operation(const char *operation, uid_t manager_uid, uid_t target_uid) +{ + char log_buf[SULOG_ENTRY_MAX_LEN]; + char timestamp[32]; + char full_comm[SULOG_COMM_LEN]; + + if (!sulog_enabled) + return; + + get_timestamp(timestamp, sizeof(timestamp)); + ksu_get_cmdline(full_comm, NULL, sizeof(full_comm)); + + sanitize_string(full_comm, sizeof(full_comm)); + + snprintf(log_buf, sizeof(log_buf), + "[%s] MANAGER_OP: OP=%s MANAGER_UID=%d TARGET_UID=%d COMM=%s PID=%d\n", + timestamp, operation ? operation : "unknown", manager_uid, target_uid, full_comm, current->pid); + + sulog_add_entry(log_buf, strlen(log_buf), manager_uid, DEDUP_MANAGER_OP); +} + +void ksu_sulog_report_syscall(uid_t uid, const char *comm, const char *syscall, const char *args) +{ + char log_buf[SULOG_ENTRY_MAX_LEN]; + char timestamp[32]; + char full_comm[SULOG_COMM_LEN]; + + if (!sulog_enabled) + return; + + get_timestamp(timestamp, sizeof(timestamp)); + ksu_get_cmdline(full_comm, comm, sizeof(full_comm)); + + sanitize_string(full_comm, sizeof(full_comm)); + + snprintf(log_buf, sizeof(log_buf), + "[%s] SYSCALL: UID=%d COMM=%s SYSCALL=%s ARGS=%s PID=%d\n", + timestamp, uid, full_comm, syscall ? syscall : "unknown", + args ? args : "none", current->pid); + + sulog_add_entry(log_buf, strlen(log_buf), uid, DEDUP_SYSCALL); +} + +int ksu_sulog_init(void) +{ + if (ksu_register_feature_handler(&sulog_handler)) { + pr_err("Failed to register sulog feature handler\n"); + } + + sulog_workqueue = alloc_workqueue("ksu_sulog", WQ_UNBOUND | WQ_HIGHPRI, 1); + if (!sulog_workqueue) { + pr_err("sulog: failed to create workqueue\n"); + return -ENOMEM; + } + + INIT_WORK(&sulog_work, sulog_work_handler); + pr_info("sulog: initialized successfully\n"); + return 0; +} + +void ksu_sulog_exit(void) +{ + struct sulog_entry *entry, *tmp; + unsigned long flags; + + ksu_unregister_feature_handler(KSU_FEATURE_SULOG); + + sulog_enabled = false; + + if (sulog_workqueue) { + flush_workqueue(sulog_workqueue); + destroy_workqueue(sulog_workqueue); + sulog_workqueue = NULL; + } + + spin_lock_irqsave(&dedup_lock, flags); + list_for_each_entry_safe(entry, tmp, &sulog_queue, list) { + list_del(&entry->list); + kfree(entry); + } + spin_unlock_irqrestore(&dedup_lock, flags); + + pr_info("sulog: cleaned up successfully\n"); +} + +#endif // __SULOG_GATE diff --git a/kernel/sulog.h b/kernel/sulog.h new file mode 100644 index 0000000..13144fb --- /dev/null +++ b/kernel/sulog.h @@ -0,0 +1,93 @@ +#ifndef __KSU_SULOG_H +#define __KSU_SULOG_H + +#include +#include +#include // needed for function dedup_calc_hash + +#define __SULOG_GATE 1 + +#if __SULOG_GATE + +extern struct timezone sys_tz; + +#define SULOG_PATH "/data/adb/ksu/log/sulog.log" +#define SULOG_MAX_SIZE (32 * 1024 * 1024) // 128MB +#define SULOG_ENTRY_MAX_LEN 512 +#define SULOG_COMM_LEN 256 +#define DEDUP_SECS 10 + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 10, 0) +static inline size_t strlcpy(char *dest, const char *src, size_t size) +{ + return strscpy(dest, src, size); +} +#endif + +#define KSU_STRSCPY(dst, src, size) \ + do { \ + if (LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0)) { \ + strscpy(dst, src, size); \ + } else { \ + strlcpy(dst, src, size); \ + } \ + } while (0) + +#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 8, 0) +#include + +static inline void time64_to_tm(time64_t totalsecs, int offset, struct tm *result) +{ + struct rtc_time rtc_tm; + rtc_time64_to_tm(totalsecs, &rtc_tm); + + result->tm_sec = rtc_tm.tm_sec; + result->tm_min = rtc_tm.tm_min; + result->tm_hour = rtc_tm.tm_hour; + result->tm_mday = rtc_tm.tm_mday; + result->tm_mon = rtc_tm.tm_mon; + result->tm_year = rtc_tm.tm_year; +} +#endif + +struct dedup_key { + u32 crc; + uid_t uid; + u8 type; + u8 _pad[1]; +}; + +struct dedup_entry { + struct dedup_key key; + u64 ts_ns; +}; + +enum { + DEDUP_SU_GRANT = 0, + DEDUP_SU_ATTEMPT, + DEDUP_PERM_CHECK, + DEDUP_MANAGER_OP, + DEDUP_SYSCALL, +}; + +static inline u32 dedup_calc_hash(const char *content, size_t len) +{ + return crc32(0, content, len); +} + +struct sulog_entry { + struct list_head list; + char content[SULOG_ENTRY_MAX_LEN]; +}; + +void ksu_sulog_report_su_grant(uid_t uid, const char *comm, const char *method); +void ksu_sulog_report_su_attempt(uid_t uid, const char *comm, const char *target_path, bool success); +void ksu_sulog_report_permission_check(uid_t uid, const char *comm, bool allowed); +void ksu_sulog_report_manager_operation(const char *operation, uid_t manager_uid, uid_t target_uid); +void ksu_sulog_report_syscall(uid_t uid, const char *comm, const char *syscall, const char *args); + +int ksu_sulog_init(void); +void ksu_sulog_exit(void); +#endif // __SULOG_GATE + +#endif /* __KSU_SULOG_H */ diff --git a/kernel/supercalls.c b/kernel/supercalls.c new file mode 100644 index 0000000..e42007f --- /dev/null +++ b/kernel/supercalls.c @@ -0,0 +1,1072 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "supercalls.h" +#include "arch.h" +#include "allowlist.h" +#include "feature.h" +#include "klog.h" // IWYU pragma: keep +#include "ksud.h" +#include "kernel_umount.h" +#include "manager.h" +#include "selinux/selinux.h" +#include "objsec.h" +#include "file_wrapper.h" +#include "syscall_hook_manager.h" +#include "throne_comm.h" +#include "dynamic_manager.h" +#include "umount_manager.h" + +#include "sulog.h" +#ifdef CONFIG_KSU_MANUAL_SU +#include "manual_su.h" +#endif + +bool ksu_uid_scanner_enabled = false; + +// Permission check functions +bool only_manager(void) +{ + return is_manager(); +} + +bool only_root(void) +{ + return current_uid().val == 0; +} + +bool manager_or_root(void) +{ + return current_uid().val == 0 || is_manager(); +} + +bool always_allow(void) +{ + return true; // No permission check +} + +bool allowed_for_su(void) +{ + bool is_allowed = is_manager() || ksu_is_allow_uid_for_current(current_uid().val); +#if __SULOG_GATE + ksu_sulog_report_permission_check(current_uid().val, current->comm, is_allowed); +#endif + return is_allowed; +} + +static void init_uid_scanner(void) +{ + ksu_uid_init(); + do_load_throne_state(NULL); + + if (ksu_uid_scanner_enabled) { + int ret = ksu_throne_comm_init(); + if (ret != 0) { + pr_err("Failed to initialize throne communication: %d\n", ret); + } + } +} + +static int do_grant_root(void __user *arg) +{ + // we already check uid above on allowed_for_su() + + pr_info("allow root for: %d\n", current_uid().val); + escape_with_root_profile(); + + return 0; +} + +static int do_get_info(void __user *arg) +{ + struct ksu_get_info_cmd cmd = {.version = KERNEL_SU_VERSION, .flags = 0}; + +#ifdef MODULE + cmd.flags |= 0x1; +#endif + if (is_manager()) { + cmd.flags |= 0x2; + } + cmd.features = KSU_FEATURE_MAX; + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_version: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_report_event(void __user *arg) +{ + struct ksu_report_event_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + return -EFAULT; + } + + switch (cmd.event) { + case EVENT_POST_FS_DATA: { + static bool post_fs_data_lock = false; + if (!post_fs_data_lock) { + post_fs_data_lock = true; + pr_info("post-fs-data triggered\n"); + on_post_fs_data(); + init_uid_scanner(); +#if __SULOG_GATE + ksu_sulog_init(); +#endif + ksu_dynamic_manager_init(); + } + break; + } + case EVENT_BOOT_COMPLETED: { + static bool boot_complete_lock = false; + if (!boot_complete_lock) { + boot_complete_lock = true; + pr_info("boot_complete triggered\n"); + on_boot_completed(); + } + break; + } + case EVENT_MODULE_MOUNTED: { + pr_info("module mounted!\n"); + on_module_mounted(); + break; + } + default: + break; + } + + return 0; +} + +static int do_set_sepolicy(void __user *arg) +{ + struct ksu_set_sepolicy_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + return -EFAULT; + } + + return handle_sepolicy(cmd.cmd, (void __user *)cmd.arg); +} + +static int do_check_safemode(void __user *arg) +{ + struct ksu_check_safemode_cmd cmd; + + cmd.in_safe_mode = ksu_is_safe_mode(); + + if (cmd.in_safe_mode) { + pr_warn("safemode enabled!\n"); + } + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("check_safemode: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_get_allow_list(void __user *arg) +{ + struct ksu_get_allow_list_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + return -EFAULT; + } + + bool success = ksu_get_allow_list((int *)cmd.uids, (int *)&cmd.count, true); + + if (!success) { + return -EFAULT; + } + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_allow_list: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_get_deny_list(void __user *arg) +{ + struct ksu_get_allow_list_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + return -EFAULT; + } + + bool success = ksu_get_allow_list((int *)cmd.uids, (int *)&cmd.count, false); + + if (!success) { + return -EFAULT; + } + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_deny_list: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_uid_granted_root(void __user *arg) +{ + struct ksu_uid_granted_root_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + return -EFAULT; + } + + cmd.granted = ksu_is_allow_uid_for_current(cmd.uid); + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("uid_granted_root: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_uid_should_umount(void __user *arg) +{ + struct ksu_uid_should_umount_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + return -EFAULT; + } + + cmd.should_umount = ksu_uid_should_umount(cmd.uid); + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("uid_should_umount: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_get_manager_uid(void __user *arg) +{ + struct ksu_get_manager_uid_cmd cmd; + + cmd.uid = ksu_get_manager_uid(); + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_manager_uid: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_get_app_profile(void __user *arg) +{ + struct ksu_get_app_profile_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("get_app_profile: copy_from_user failed\n"); + return -EFAULT; + } + + if (!ksu_get_app_profile(&cmd.profile)) { + return -ENOENT; + } + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_app_profile: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_set_app_profile(void __user *arg) +{ + struct ksu_set_app_profile_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("set_app_profile: copy_from_user failed\n"); + return -EFAULT; + } + + if (!ksu_set_app_profile(&cmd.profile, true)) { +#if __SULOG_GATE + ksu_sulog_report_manager_operation("SET_APP_PROFILE", + current_uid().val, cmd.profile.current_uid); +#endif + return -EFAULT; + } + + return 0; +} + +static int do_get_feature(void __user *arg) +{ + struct ksu_get_feature_cmd cmd; + bool supported; + int ret; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("get_feature: copy_from_user failed\n"); + return -EFAULT; + } + + ret = ksu_get_feature(cmd.feature_id, &cmd.value, &supported); + cmd.supported = supported ? 1 : 0; + + if (ret && supported) { + pr_err("get_feature: failed for feature %u: %d\n", cmd.feature_id, ret); + return ret; + } + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_feature: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_set_feature(void __user *arg) +{ + struct ksu_set_feature_cmd cmd; + int ret; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("set_feature: copy_from_user failed\n"); + return -EFAULT; + } + + ret = ksu_set_feature(cmd.feature_id, cmd.value); + if (ret) { + pr_err("set_feature: failed for feature %u: %d\n", cmd.feature_id, ret); + return ret; + } + + return 0; +} + +static int do_get_wrapper_fd(void __user *arg) { + if (!ksu_file_sid) { + return -EINVAL; + } + + struct ksu_get_wrapper_fd_cmd cmd; + int ret; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("get_wrapper_fd: copy_from_user failed\n"); + return -EFAULT; + } + + struct file* f = fget(cmd.fd); + if (!f) { + return -EBADF; + } + + struct ksu_file_wrapper *data = ksu_create_file_wrapper(f); + if (data == NULL) { + ret = -ENOMEM; + goto put_orig_file; + } + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 12, 0) +#define getfd_secure anon_inode_create_getfd +#else +#define getfd_secure anon_inode_getfd_secure +#endif + ret = getfd_secure("[ksu_fdwrapper]", &data->ops, data, f->f_flags, NULL); + if (ret < 0) { + pr_err("ksu_fdwrapper: getfd failed: %d\n", ret); + goto put_wrapper_data; + } + struct file* pf = fget(ret); + + struct inode* wrapper_inode = file_inode(pf); + // copy original inode mode + wrapper_inode->i_mode = file_inode(f)->i_mode; + struct inode_security_struct *sec = selinux_inode(wrapper_inode); + if (sec) { + sec->sid = ksu_file_sid; + } + + fput(pf); + goto put_orig_file; +put_wrapper_data: + ksu_delete_file_wrapper(data); +put_orig_file: + fput(f); + + return ret; +} + +static int do_manage_mark(void __user *arg) +{ + struct ksu_manage_mark_cmd cmd; + int ret = 0; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("manage_mark: copy_from_user failed\n"); + return -EFAULT; + } + + switch (cmd.operation) { + case KSU_MARK_GET: { + // Get task mark status + ret = ksu_get_task_mark(cmd.pid); + if (ret < 0) { + pr_err("manage_mark: get failed for pid %d: %d\n", cmd.pid, ret); + return ret; + } + cmd.result = (u32)ret; + break; + } + case KSU_MARK_MARK: { + if (cmd.pid == 0) { + ksu_mark_all_process(); + } else { + ret = ksu_set_task_mark(cmd.pid, true); + if (ret < 0) { + pr_err("manage_mark: set_mark failed for pid %d: %d\n", cmd.pid, + ret); + return ret; + } + } + break; + } + case KSU_MARK_UNMARK: { + if (cmd.pid == 0) { + ksu_unmark_all_process(); + } else { + ret = ksu_set_task_mark(cmd.pid, false); + if (ret < 0) { + pr_err("manage_mark: set_unmark failed for pid %d: %d\n", + cmd.pid, ret); + return ret; + } + } + break; + } + case KSU_MARK_REFRESH: { + ksu_mark_running_process(); + pr_info("manage_mark: refreshed running processes\n"); + break; + } + default: { + pr_err("manage_mark: invalid operation %u\n", cmd.operation); + return -EINVAL; + } + } + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("manage_mark: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_nuke_ext4_sysfs(void __user *arg) +{ + struct ksu_nuke_ext4_sysfs_cmd cmd; + char mnt[256]; + long ret; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) + return -EFAULT; + + if (!cmd.arg) + return -EINVAL; + + memset(mnt, 0, sizeof(mnt)); + + ret = strncpy_from_user(mnt, cmd.arg, sizeof(mnt)); + if (ret < 0) { + pr_err("nuke ext4 copy mnt failed: %ld\\n", ret); + return -EFAULT; // 或者 return ret; + } + + if (ret == sizeof(mnt)) { + pr_err("nuke ext4 mnt path too long\\n"); + return -ENAMETOOLONG; + } + + pr_info("do_nuke_ext4_sysfs: %s\n", mnt); + + return nuke_ext4_sysfs(mnt); +} + +struct list_head mount_list = LIST_HEAD_INIT(mount_list); +DECLARE_RWSEM(mount_list_lock); + +static int add_try_umount(void __user *arg) +{ + struct mount_entry *new_entry, *entry, *tmp; + struct ksu_add_try_umount_cmd cmd; + char buf[256] = {0}; + + if (copy_from_user(&cmd, arg, sizeof cmd)) + return -EFAULT; + + switch (cmd.mode) { + case KSU_UMOUNT_WIPE: { + struct mount_entry *entry, *tmp; + down_write(&mount_list_lock); + list_for_each_entry_safe(entry, tmp, &mount_list, list) { + pr_info("wipe_umount_list: removing entry: %s\n", entry->umountable); + list_del(&entry->list); + kfree(entry->umountable); + kfree(entry); + } + up_write(&mount_list_lock); + + return 0; + } + + case KSU_UMOUNT_ADD: { + long len = strncpy_from_user(buf, (const char __user *)cmd.arg, 256); + if (len <= 0) + return -EFAULT; + + buf[sizeof(buf) - 1] = '\0'; + + new_entry = kzalloc(sizeof(*new_entry), GFP_KERNEL); + if (!new_entry) + return -ENOMEM; + + new_entry->umountable = kstrdup(buf, GFP_KERNEL); + if (!new_entry->umountable) { + kfree(new_entry); + return -1; + } + + down_write(&mount_list_lock); + + // disallow dupes + // if this gets too many, we can consider moving this whole task to a kthread + list_for_each_entry(entry, &mount_list, list) { + if (!strcmp(entry->umountable, buf)) { + pr_info("cmd_add_try_umount: %s is already here!\n", buf); + up_write(&mount_list_lock); + kfree(new_entry->umountable); + kfree(new_entry); + return -1; + } + } + + // now check flags and add + // this also serves as a null check + if (cmd.flags) + new_entry->flags = cmd.flags; + else + new_entry->flags = 0; + + // debug + list_add(&new_entry->list, &mount_list); + up_write(&mount_list_lock); + pr_info("cmd_add_try_umount: %s added!\n", buf); + + return 0; + } + + // this is just strcmp'd wipe anyway + case KSU_UMOUNT_DEL: { + long len = strncpy_from_user(buf, (const char __user *)cmd.arg, sizeof(buf) - 1); + if (len <= 0) + return -EFAULT; + + buf[sizeof(buf) - 1] = '\0'; + + down_write(&mount_list_lock); + list_for_each_entry_safe(entry, tmp, &mount_list, list) { + if (!strcmp(entry->umountable, buf)) { + pr_info("cmd_add_try_umount: entry removed: %s\n", entry->umountable); + list_del(&entry->list); + kfree(entry->umountable); + kfree(entry); + } + } + up_write(&mount_list_lock); + + return 0; + } + + default: { + pr_err("cmd_add_try_umount: invalid operation %u\n", cmd.mode); + return -EINVAL; + } + + } // switch(cmd.mode) + + return 0; +} + +// 100. GET_FULL_VERSION - Get full version string +static int do_get_full_version(void __user *arg) +{ + struct ksu_get_full_version_cmd cmd = {0}; + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0) + strscpy(cmd.version_full, KSU_VERSION_FULL, sizeof(cmd.version_full)); +#else + strlcpy(cmd.version_full, KSU_VERSION_FULL, sizeof(cmd.version_full)); +#endif + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_full_version: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +// 101. HOOK_TYPE - Get hook type +static int do_get_hook_type(void __user *arg) +{ + struct ksu_hook_type_cmd cmd = {0}; + const char *type = "Tracepoint"; + +#if defined(KSU_MANUAL_HOOK) + type = "Manual"; +#endif + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 13, 0) + strscpy(cmd.hook_type, type, sizeof(cmd.hook_type)); +#else + strlcpy(cmd.hook_type, type, sizeof(cmd.hook_type)); +#endif + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_hook_type: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +// 102. ENABLE_KPM - Check if KPM is enabled +static int do_enable_kpm(void __user *arg) +{ + struct ksu_enable_kpm_cmd cmd; + + cmd.enabled = IS_ENABLED(CONFIG_KPM); + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("enable_kpm: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_dynamic_manager(void __user *arg) +{ + struct ksu_dynamic_manager_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("dynamic_manager: copy_from_user failed\n"); + return -EFAULT; + } + + int ret = ksu_handle_dynamic_manager(&cmd.config); + if (ret) + return ret; + + if (cmd.config.operation == DYNAMIC_MANAGER_OP_GET && + copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("dynamic_manager: copy_to_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_get_managers(void __user *arg) +{ + struct ksu_get_managers_cmd cmd; + + int ret = ksu_get_active_managers(&cmd.manager_info); + if (ret) + return ret; + + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("get_managers: copy_from_user failed\n"); + return -EFAULT; + } + + return 0; +} + +static int do_enable_uid_scanner(void __user *arg) +{ + struct ksu_enable_uid_scanner_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("enable_uid_scanner: copy_from_user failed\n"); + return -EFAULT; + } + + switch (cmd.operation) { + case UID_SCANNER_OP_GET_STATUS: { + bool status = ksu_uid_scanner_enabled; + if (copy_to_user((void __user *)cmd.status_ptr, &status, sizeof(status))) { + pr_err("enable_uid_scanner: copy status failed\n"); + return -EFAULT; + } + break; + } + case UID_SCANNER_OP_TOGGLE: { + bool enabled = cmd.enabled; + + if (enabled == ksu_uid_scanner_enabled) { + pr_info("enable_uid_scanner: no need to change, already %s\n", + enabled ? "enabled" : "disabled"); + break; + } + + if (enabled) { + // Enable UID scanner + int ret = ksu_throne_comm_init(); + if (ret != 0) { + pr_err("enable_uid_scanner: failed to initialize: %d\n", ret); + return -EFAULT; + } + pr_info("enable_uid_scanner: enabled\n"); + } else { + // Disable UID scanner + ksu_throne_comm_exit(); + pr_info("enable_uid_scanner: disabled\n"); + } + + ksu_uid_scanner_enabled = enabled; + ksu_throne_comm_save_state(); + break; + } + case UID_SCANNER_OP_CLEAR_ENV: { + // Clear environment (force exit) + ksu_throne_comm_exit(); + ksu_uid_scanner_enabled = false; + ksu_throne_comm_save_state(); + pr_info("enable_uid_scanner: environment cleared\n"); + break; + } + default: + pr_err("enable_uid_scanner: invalid operation\n"); + return -EINVAL; + } + + return 0; +} + +#ifdef CONFIG_KSU_MANUAL_SU +static bool system_uid_check(void) +{ + return current_uid().val <= 2000; +} + +static int do_manual_su(void __user *arg) +{ + struct ksu_manual_su_cmd cmd; + struct manual_su_request request; + int res; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("manual_su: copy_from_user failed\n"); + return -EFAULT; + } + + pr_info("manual_su request, option=%d, uid=%d, pid=%d\n", + cmd.option, cmd.target_uid, cmd.target_pid); + + memset(&request, 0, sizeof(request)); + request.target_uid = cmd.target_uid; + request.target_pid = cmd.target_pid; + + if (cmd.option == MANUAL_SU_OP_GENERATE_TOKEN || + cmd.option == MANUAL_SU_OP_ESCALATE) { + memcpy(request.token_buffer, cmd.token_buffer, sizeof(request.token_buffer)); + } + + res = ksu_handle_manual_su_request(cmd.option, &request); + + if (cmd.option == MANUAL_SU_OP_GENERATE_TOKEN && res == 0) { + memcpy(cmd.token_buffer, request.token_buffer, sizeof(cmd.token_buffer)); + if (copy_to_user(arg, &cmd, sizeof(cmd))) { + pr_err("manual_su: copy_to_user failed\n"); + return -EFAULT; + } + } + + return res; +} +#endif + +static int do_umount_manager(void __user *arg) +{ + struct ksu_umount_manager_cmd cmd; + + if (copy_from_user(&cmd, arg, sizeof(cmd))) { + pr_err("umount_manager: copy_from_user failed\n"); + return -EFAULT; + } + + switch (cmd.operation) { + case UMOUNT_OP_ADD: { + return ksu_umount_manager_add(cmd.path, cmd.flags, false); + } + case UMOUNT_OP_REMOVE: { + return ksu_umount_manager_remove(cmd.path); + } + case UMOUNT_OP_LIST: { + struct ksu_umount_entry_info __user *entries = + (struct ksu_umount_entry_info __user *)cmd.entries_ptr; + return ksu_umount_manager_get_entries(entries, &cmd.count); + } + case UMOUNT_OP_CLEAR_CUSTOM: { + return ksu_umount_manager_clear_custom(); + } + default: + return -EINVAL; + } +} + +// IOCTL handlers mapping table +static const struct ksu_ioctl_cmd_map ksu_ioctl_handlers[] = { + { .cmd = KSU_IOCTL_GRANT_ROOT, .name = "GRANT_ROOT", .handler = do_grant_root, .perm_check = allowed_for_su }, + { .cmd = KSU_IOCTL_GET_INFO, .name = "GET_INFO", .handler = do_get_info, .perm_check = always_allow }, + { .cmd = KSU_IOCTL_REPORT_EVENT, .name = "REPORT_EVENT", .handler = do_report_event, .perm_check = only_root }, + { .cmd = KSU_IOCTL_SET_SEPOLICY, .name = "SET_SEPOLICY", .handler = do_set_sepolicy, .perm_check = only_root }, + { .cmd = KSU_IOCTL_CHECK_SAFEMODE, .name = "CHECK_SAFEMODE", .handler = do_check_safemode, .perm_check = always_allow }, + { .cmd = KSU_IOCTL_GET_ALLOW_LIST, .name = "GET_ALLOW_LIST", .handler = do_get_allow_list, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_GET_DENY_LIST, .name = "GET_DENY_LIST", .handler = do_get_deny_list, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_UID_GRANTED_ROOT, .name = "UID_GRANTED_ROOT", .handler = do_uid_granted_root, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_UID_SHOULD_UMOUNT, .name = "UID_SHOULD_UMOUNT", .handler = do_uid_should_umount, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_GET_MANAGER_UID, .name = "GET_MANAGER_UID", .handler = do_get_manager_uid, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_GET_APP_PROFILE, .name = "GET_APP_PROFILE", .handler = do_get_app_profile, .perm_check = only_manager }, + { .cmd = KSU_IOCTL_SET_APP_PROFILE, .name = "SET_APP_PROFILE", .handler = do_set_app_profile, .perm_check = only_manager }, + { .cmd = KSU_IOCTL_GET_FEATURE, .name = "GET_FEATURE", .handler = do_get_feature, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_SET_FEATURE, .name = "SET_FEATURE", .handler = do_set_feature, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_GET_WRAPPER_FD, .name = "GET_WRAPPER_FD", .handler = do_get_wrapper_fd, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_MANAGE_MARK, .name = "MANAGE_MARK", .handler = do_manage_mark, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_NUKE_EXT4_SYSFS, .name = "NUKE_EXT4_SYSFS", .handler = do_nuke_ext4_sysfs, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_ADD_TRY_UMOUNT, .name = "ADD_TRY_UMOUNT", .handler = add_try_umount, .perm_check = manager_or_root }, + { .cmd = KSU_IOCTL_GET_FULL_VERSION,.name = "GET_FULL_VERSION", .handler = do_get_full_version, .perm_check = always_allow}, + { .cmd = KSU_IOCTL_HOOK_TYPE,.name = "GET_HOOK_TYPE", .handler = do_get_hook_type, .perm_check = manager_or_root}, + { .cmd = KSU_IOCTL_ENABLE_KPM, .name = "GET_ENABLE_KPM", .handler = do_enable_kpm, .perm_check = manager_or_root}, + { .cmd = KSU_IOCTL_DYNAMIC_MANAGER, .name = "SET_DYNAMIC_MANAGER", .handler = do_dynamic_manager, .perm_check = manager_or_root}, + { .cmd = KSU_IOCTL_GET_MANAGERS, .name = "GET_MANAGERS", .handler = do_get_managers, .perm_check = manager_or_root}, + { .cmd = KSU_IOCTL_ENABLE_UID_SCANNER, .name = "SET_ENABLE_UID_SCANNER", .handler = do_enable_uid_scanner, .perm_check = manager_or_root}, +#ifdef CONFIG_KSU_MANUAL_SU + { .cmd = KSU_IOCTL_MANUAL_SU, .name = "MANUAL_SU", .handler = do_manual_su, .perm_check = system_uid_check}, +#endif +#ifdef CONFIG_KPM + { .cmd = KSU_IOCTL_KPM, .name = "KPM_OPERATION", .handler = do_kpm, .perm_check = manager_or_root}, +#endif + { .cmd = KSU_IOCTL_UMOUNT_MANAGER, .name = "UMOUNT_MANAGER", .handler = do_umount_manager, .perm_check = manager_or_root}, + { .cmd = 0, .name = NULL, .handler = NULL, .perm_check = NULL} // Sentine +}; + +struct ksu_install_fd_tw { + struct callback_head cb; + int __user *outp; +}; + +static void ksu_install_fd_tw_func(struct callback_head *cb) +{ + struct ksu_install_fd_tw *tw = container_of(cb, struct ksu_install_fd_tw, cb); + int fd = ksu_install_fd(); + pr_info("[%d] install ksu fd: %d\n", current->pid, fd); + + if (copy_to_user(tw->outp, &fd, sizeof(fd))) { + pr_err("install ksu fd reply err\n"); +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) + close_fd(fd); +#else + ksys_close(fd); +#endif + } + + kfree(tw); +} + +// downstream: make sure to pass arg as reference, this can allow us to extend things. +int ksu_handle_sys_reboot(int magic1, int magic2, unsigned int cmd, void __user **arg) +{ + struct ksu_install_fd_tw *tw; + + if (magic1 != KSU_INSTALL_MAGIC1) + return 0; + +#ifdef CONFIG_KSU_DEBUG + pr_info("sys_reboot: intercepted call! magic: 0x%x id: %d\n", magic1, magic2); +#endif + + // Check if this is a request to install KSU fd + if (magic2 == KSU_INSTALL_MAGIC2) { + tw = kzalloc(sizeof(*tw), GFP_ATOMIC); + if (!tw) + return 0; + + tw->outp = (int __user *)*arg; + tw->cb.func = ksu_install_fd_tw_func; + + if (task_work_add(current, &tw->cb, TWA_RESUME)) { + kfree(tw); + pr_warn("install fd add task_work failed\n"); + } + + return 0; + } + + // extensions + + return 0; +} + +#ifdef KSU_KPROBES_HOOK +// Reboot hook for installing fd +static int reboot_handler_pre(struct kprobe *p, struct pt_regs *regs) +{ + struct pt_regs *real_regs = PT_REAL_REGS(regs); + int magic1 = (int)PT_REGS_PARM1(real_regs); + int magic2 = (int)PT_REGS_PARM2(real_regs); + int cmd = (int)PT_REGS_PARM3(real_regs); + void __user **arg = (void __user **)&PT_REGS_SYSCALL_PARM4(real_regs); + + return ksu_handle_sys_reboot(magic1, magic2, cmd, arg); +} + +static struct kprobe reboot_kp = { + .symbol_name = REBOOT_SYMBOL, + .pre_handler = reboot_handler_pre, +}; +#endif + +void ksu_supercalls_init(void) +{ + int i; + + pr_info("KernelSU IOCTL Commands:\n"); + for (i = 0; ksu_ioctl_handlers[i].handler; i++) { + pr_info(" %-18s = 0x%08x\n", ksu_ioctl_handlers[i].name, ksu_ioctl_handlers[i].cmd); + } +#ifdef KSU_KPROBES_HOOK + int rc = register_kprobe(&reboot_kp); + if (rc) { + pr_err("reboot kprobe failed: %d\n", rc); + } else { + pr_info("reboot kprobe registered successfully\n"); + } +#endif +} + +void ksu_supercalls_exit(void) { +#ifdef KSU_KPROBES_HOOK + unregister_kprobe(&reboot_kp); +#endif +} + +static inline void ksu_ioctl_audit(unsigned int cmd, const char *cmd_name, uid_t uid, int ret) +{ +#if __SULOG_GATE + const char *result = (ret == 0) ? "SUCCESS" : + (ret == -EPERM) ? "DENIED" : "FAILED"; + ksu_sulog_report_syscall(uid, NULL, cmd_name, result); +#endif +} + +// IOCTL dispatcher +static long anon_ksu_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) +{ + void __user *argp = (void __user *)arg; + int i; + +#ifdef CONFIG_KSU_DEBUG + pr_info("ksu ioctl: cmd=0x%x from uid=%d\n", cmd, current_uid().val); +#endif + + for (i = 0; ksu_ioctl_handlers[i].handler; i++) { + if (cmd == ksu_ioctl_handlers[i].cmd) { + // Check permission first + if (ksu_ioctl_handlers[i].perm_check && + !ksu_ioctl_handlers[i].perm_check()) { + pr_warn("ksu ioctl: permission denied for cmd=0x%x uid=%d\n", + cmd, current_uid().val); + ksu_ioctl_audit(cmd, ksu_ioctl_handlers[i].name, + current_uid().val, -EPERM); + return -EPERM; + } + // Execute handler + int ret = ksu_ioctl_handlers[i].handler(argp); + ksu_ioctl_audit(cmd, ksu_ioctl_handlers[i].name, + current_uid().val, ret); + return ret; + } + } + + pr_warn("ksu ioctl: unsupported command 0x%x\n", cmd); + return -ENOTTY; +} + +// File release handler +static int anon_ksu_release(struct inode *inode, struct file *filp) +{ + pr_info("ksu fd released\n"); + return 0; +} + +// File operations structure +static const struct file_operations anon_ksu_fops = { + .owner = THIS_MODULE, + .unlocked_ioctl = anon_ksu_ioctl, + .compat_ioctl = anon_ksu_ioctl, + .release = anon_ksu_release, +}; + +// Install KSU fd to current process +int ksu_install_fd(void) +{ + struct file *filp; + int fd; + + // Get unused fd + fd = get_unused_fd_flags(O_CLOEXEC); + if (fd < 0) { + pr_err("ksu_install_fd: failed to get unused fd\n"); + return fd; + } + + // Create anonymous inode file + filp = anon_inode_getfile("[ksu_driver]", &anon_ksu_fops, NULL, O_RDWR | O_CLOEXEC); + if (IS_ERR(filp)) { + pr_err("ksu_install_fd: failed to create anon inode file\n"); + put_unused_fd(fd); + return PTR_ERR(filp); + } + + // Install fd + fd_install(fd, filp); + +#if __SULOG_GATE + ksu_sulog_report_permission_check(current_uid().val, current->comm, fd >= 0); +#endif + + pr_info("ksu fd installed: %d for pid %d\n", fd, current->pid); + + return fd; +} \ No newline at end of file diff --git a/kernel/supercalls.h b/kernel/supercalls.h new file mode 100644 index 0000000..6caca80 --- /dev/null +++ b/kernel/supercalls.h @@ -0,0 +1,197 @@ +#ifndef __KSU_H_SUPERCALLS +#define __KSU_H_SUPERCALLS + +#include +#include +#include "ksu.h" +#include "app_profile.h" + +#ifdef CONFIG_KPM +#include "kpm/kpm.h" +#endif + +// Magic numbers for reboot hook to install fd +#define KSU_INSTALL_MAGIC1 0xDEADBEEF +#define KSU_INSTALL_MAGIC2 0xCAFEBABE + +// Command structures for ioctl + +struct ksu_become_daemon_cmd { + __u8 token[65]; // Input: daemon token (null-terminated) +}; + +struct ksu_get_info_cmd { + __u32 version; // Output: KERNEL_SU_VERSION + __u32 flags; // Output: flags (bit 0: MODULE mode) + __u32 features; // Output: max feature ID supported +}; + +struct ksu_report_event_cmd { + __u32 event; // Input: EVENT_POST_FS_DATA, EVENT_BOOT_COMPLETED, etc. +}; + +struct ksu_set_sepolicy_cmd { + __u64 cmd; // Input: sepolicy command + __aligned_u64 arg; // Input: sepolicy argument pointer +}; + +struct ksu_check_safemode_cmd { + __u8 in_safe_mode; // Output: true if in safe mode, false otherwise +}; + +struct ksu_get_allow_list_cmd { + __u32 uids[128]; // Output: array of allowed/denied UIDs + __u32 count; // Output: number of UIDs in array + __u8 allow; // Input: true for allow list, false for deny list +}; + +struct ksu_uid_granted_root_cmd { + __u32 uid; // Input: target UID to check + __u8 granted; // Output: true if granted, false otherwise +}; + +struct ksu_uid_should_umount_cmd { + __u32 uid; // Input: target UID to check + __u8 should_umount; // Output: true if should umount, false otherwise +}; + +struct ksu_get_manager_uid_cmd { + __u32 uid; // Output: manager UID +}; + +struct ksu_get_app_profile_cmd { + struct app_profile profile; // Input/Output: app profile structure +}; + +struct ksu_set_app_profile_cmd { + struct app_profile profile; // Input: app profile structure +}; + +struct ksu_get_feature_cmd { + __u32 feature_id; // Input: feature ID (enum ksu_feature_id) + __u64 value; // Output: feature value/state + __u8 supported; // Output: true if feature is supported, false otherwise +}; + +struct ksu_set_feature_cmd { + __u32 feature_id; // Input: feature ID (enum ksu_feature_id) + __u64 value; // Input: feature value/state to set +}; + +struct ksu_get_wrapper_fd_cmd { + __u32 fd; // Input: userspace fd + __u32 flags; // Input: flags of userspace fd +}; + +struct ksu_manage_mark_cmd { + __u32 operation; // Input: KSU_MARK_* + __s32 pid; // Input: target pid (0 for all processes) + __u32 result; // Output: for get operation - mark status or reg_count +}; + +#define KSU_MARK_GET 1 +#define KSU_MARK_MARK 2 +#define KSU_MARK_UNMARK 3 +#define KSU_MARK_REFRESH 4 + +struct ksu_nuke_ext4_sysfs_cmd { + __aligned_u64 arg; // Input: mnt pointer +}; + +struct ksu_add_try_umount_cmd { + __aligned_u64 arg; // char ptr, this is the mountpoint + __u32 flags; // this is the flag we use for it + __u8 mode; // denotes what to do with it 0:wipe_list 1:add_to_list 2:delete_entry +}; + +#define KSU_UMOUNT_WIPE 0 // ignore everything and wipe list +#define KSU_UMOUNT_ADD 1 // add entry (path + flags) +#define KSU_UMOUNT_DEL 2 // delete entry, strcmp + + +// Other command structures +struct ksu_get_full_version_cmd { + char version_full[KSU_FULL_VERSION_STRING]; // Output: full version string +}; + +struct ksu_hook_type_cmd { + char hook_type[32]; // Output: hook type string +}; + +struct ksu_enable_kpm_cmd { + __u8 enabled; // Output: true if KPM is enabled +}; + +struct ksu_dynamic_manager_cmd { + struct dynamic_manager_user_config config; // Input/Output: dynamic manager config +}; + +struct ksu_get_managers_cmd { + struct manager_list_info manager_info; // Output: manager list information +}; + +struct ksu_enable_uid_scanner_cmd { + __u32 operation; // Input: operation type (UID_SCANNER_OP_GET_STATUS, UID_SCANNER_OP_TOGGLE, UID_SCANNER_OP_CLEAR_ENV) + __u32 enabled; // Input: enable or disable (for UID_SCANNER_OP_TOGGLE) + void __user *status_ptr; // Input: pointer to store status (for UID_SCANNER_OP_GET_STATUS) +}; + +#ifdef CONFIG_KSU_MANUAL_SU +struct ksu_manual_su_cmd { + __u32 option; // Input: operation type (MANUAL_SU_OP_GENERATE_TOKEN, MANUAL_SU_OP_ESCALATE, MANUAL_SU_OP_ADD_PENDING) + __u32 target_uid; // Input: target UID + __u32 target_pid; // Input: target PID + char token_buffer[33]; // Input/Output: token buffer +}; +#endif + +// IOCTL command definitions +#define KSU_IOCTL_GRANT_ROOT _IOC(_IOC_NONE, 'K', 1, 0) +#define KSU_IOCTL_GET_INFO _IOC(_IOC_READ, 'K', 2, 0) +#define KSU_IOCTL_REPORT_EVENT _IOC(_IOC_WRITE, 'K', 3, 0) +#define KSU_IOCTL_SET_SEPOLICY _IOC(_IOC_READ|_IOC_WRITE, 'K', 4, 0) +#define KSU_IOCTL_CHECK_SAFEMODE _IOC(_IOC_READ, 'K', 5, 0) +#define KSU_IOCTL_GET_ALLOW_LIST _IOC(_IOC_READ|_IOC_WRITE, 'K', 6, 0) +#define KSU_IOCTL_GET_DENY_LIST _IOC(_IOC_READ|_IOC_WRITE, 'K', 7, 0) +#define KSU_IOCTL_UID_GRANTED_ROOT _IOC(_IOC_READ|_IOC_WRITE, 'K', 8, 0) +#define KSU_IOCTL_UID_SHOULD_UMOUNT _IOC(_IOC_READ|_IOC_WRITE, 'K', 9, 0) +#define KSU_IOCTL_GET_MANAGER_UID _IOC(_IOC_READ, 'K', 10, 0) +#define KSU_IOCTL_GET_APP_PROFILE _IOC(_IOC_READ|_IOC_WRITE, 'K', 11, 0) +#define KSU_IOCTL_SET_APP_PROFILE _IOC(_IOC_WRITE, 'K', 12, 0) +#define KSU_IOCTL_GET_FEATURE _IOC(_IOC_READ|_IOC_WRITE, 'K', 13, 0) +#define KSU_IOCTL_SET_FEATURE _IOC(_IOC_WRITE, 'K', 14, 0) +#define KSU_IOCTL_GET_WRAPPER_FD _IOC(_IOC_WRITE, 'K', 15, 0) +#define KSU_IOCTL_MANAGE_MARK _IOC(_IOC_READ|_IOC_WRITE, 'K', 16, 0) +#define KSU_IOCTL_NUKE_EXT4_SYSFS _IOC(_IOC_WRITE, 'K', 17, 0) +#define KSU_IOCTL_ADD_TRY_UMOUNT _IOC(_IOC_WRITE, 'K', 18, 0) +// Other IOCTL command definitions +#define KSU_IOCTL_GET_FULL_VERSION _IOC(_IOC_READ, 'K', 100, 0) +#define KSU_IOCTL_HOOK_TYPE _IOC(_IOC_READ, 'K', 101, 0) +#define KSU_IOCTL_ENABLE_KPM _IOC(_IOC_READ, 'K', 102, 0) +#define KSU_IOCTL_DYNAMIC_MANAGER _IOC(_IOC_READ|_IOC_WRITE, 'K', 103, 0) +#define KSU_IOCTL_GET_MANAGERS _IOC(_IOC_READ|_IOC_WRITE, 'K', 104, 0) +#define KSU_IOCTL_ENABLE_UID_SCANNER _IOC(_IOC_READ|_IOC_WRITE, 'K', 105, 0) +#ifdef CONFIG_KSU_MANUAL_SU +#define KSU_IOCTL_MANUAL_SU _IOC(_IOC_READ|_IOC_WRITE, 'K', 106, 0) +#endif +#define KSU_IOCTL_UMOUNT_MANAGER _IOC(_IOC_READ|_IOC_WRITE, 'K', 107, 0) + +// IOCTL handler types +typedef int (*ksu_ioctl_handler_t)(void __user *arg); +typedef bool (*ksu_perm_check_t)(void); + +// IOCTL command mapping +struct ksu_ioctl_cmd_map { + unsigned int cmd; + const char *name; + ksu_ioctl_handler_t handler; + ksu_perm_check_t perm_check; // Permission check function +}; + +// Install KSU fd to current process +int ksu_install_fd(void); + +void ksu_supercalls_init(void); +void ksu_supercalls_exit(void); + +#endif // __KSU_H_SUPERCALLS \ No newline at end of file diff --git a/kernel/syscall_hook_manager.c b/kernel/syscall_hook_manager.c new file mode 100644 index 0000000..14d258a --- /dev/null +++ b/kernel/syscall_hook_manager.c @@ -0,0 +1,374 @@ +#include "linux/compiler.h" +#include "linux/cred.h" +#include "linux/printk.h" +#include "selinux/selinux.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#include "allowlist.h" +#include "arch.h" +#include "klog.h" // IWYU pragma: keep +#include "syscall_hook_manager.h" +#include "sucompat.h" +#include "setuid_hook.h" +#include "selinux/selinux.h" + +// Tracepoint registration count management +// == 1: just us +// > 1: someone else is also using syscall tracepoint e.g. ftrace +static int tracepoint_reg_count = 0; +static DEFINE_SPINLOCK(tracepoint_reg_lock); + +void ksu_clear_task_tracepoint_flag_if_needed(struct task_struct *t) +{ + unsigned long flags; + spin_lock_irqsave(&tracepoint_reg_lock, flags); + if (tracepoint_reg_count <= 1) { + ksu_clear_task_tracepoint_flag(t); + } + spin_unlock_irqrestore(&tracepoint_reg_lock, flags); +} + +// Process marking management +static void handle_process_mark(bool mark) +{ + struct task_struct *p, *t; + read_lock(&tasklist_lock); + for_each_process_thread(p, t) { + if (mark) + ksu_set_task_tracepoint_flag(t); + else + ksu_clear_task_tracepoint_flag(t); + } + read_unlock(&tasklist_lock); +} + +void ksu_mark_all_process(void) +{ + handle_process_mark(true); + pr_info("hook_manager: mark all user process done!\n"); +} + +void ksu_unmark_all_process(void) +{ + handle_process_mark(false); + pr_info("hook_manager: unmark all user process done!\n"); +} + +static void ksu_mark_running_process_locked() +{ + struct task_struct *p, *t; + read_lock(&tasklist_lock); + for_each_process_thread (p, t) { + if (!t->mm) { // only user processes + continue; + } + int uid = task_uid(t).val; + const struct cred *cred = get_task_cred(t); + bool ksu_root_process = + uid == 0 && is_task_ksu_domain(cred); + bool is_zygote_process = is_zygote(cred); + bool is_shell = uid == 2000; + // before boot completed, we shall mark init for marking zygote + bool is_init = t->pid == 1; + if (ksu_root_process || is_zygote_process || is_shell || is_init + || ksu_is_allow_uid(uid)) { + ksu_set_task_tracepoint_flag(t); + pr_info("hook_manager: mark process: pid:%d, uid: %d, comm:%s\n", + t->pid, uid, t->comm); + } else { + ksu_clear_task_tracepoint_flag(t); + pr_info("hook_manager: unmark process: pid:%d, uid: %d, comm:%s\n", + t->pid, uid, t->comm); + } + put_cred(cred); + } + read_unlock(&tasklist_lock); +} + +void ksu_mark_running_process() +{ + unsigned long flags; + spin_lock_irqsave(&tracepoint_reg_lock, flags); + if (tracepoint_reg_count <= 1) { + ksu_mark_running_process_locked(); + } else { + pr_info("hook_manager: not mark running process since syscall tracepoint is in use\n"); + } + spin_unlock_irqrestore(&tracepoint_reg_lock, flags); +} + +// Get task mark status +// Returns: 1 if marked, 0 if not marked, -ESRCH if task not found +int ksu_get_task_mark(pid_t pid) +{ + struct task_struct *task; + int marked = -ESRCH; + + rcu_read_lock(); + task = find_task_by_vpid(pid); + if (task) { + get_task_struct(task); + rcu_read_unlock(); +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) + marked = test_task_syscall_work(task, SYSCALL_TRACEPOINT) ? 1 : 0; +#else + marked = test_tsk_thread_flag(task, TIF_SYSCALL_TRACEPOINT) ? 1 : 0; +#endif + put_task_struct(task); + } else { + rcu_read_unlock(); + } + + return marked; +} + +// Set task mark status +// Returns: 0 on success, -ESRCH if task not found +int ksu_set_task_mark(pid_t pid, bool mark) +{ + struct task_struct *task; + int ret = -ESRCH; + + rcu_read_lock(); + task = find_task_by_vpid(pid); + if (task) { + get_task_struct(task); + rcu_read_unlock(); + if (mark) { + ksu_set_task_tracepoint_flag(task); + pr_info("hook_manager: marked task pid=%d comm=%s\n", pid, task->comm); + } else { + ksu_clear_task_tracepoint_flag(task); + pr_info("hook_manager: unmarked task pid=%d comm=%s\n", pid, task->comm); + } + put_task_struct(task); + ret = 0; + } else { + rcu_read_unlock(); + } + + return ret; +} + +#ifdef CONFIG_KRETPROBES + +static struct kretprobe *init_kretprobe(const char *name, + kretprobe_handler_t handler) +{ + struct kretprobe *rp = kzalloc(sizeof(struct kretprobe), GFP_KERNEL); + if (!rp) + return NULL; + rp->kp.symbol_name = name; + rp->handler = handler; + rp->data_size = 0; + rp->maxactive = 0; + + int ret = register_kretprobe(rp); + pr_info("hook_manager: register_%s kretprobe: %d\n", name, ret); + if (ret) { + kfree(rp); + return NULL; + } + + return rp; +} + +static void destroy_kretprobe(struct kretprobe **rp_ptr) +{ + struct kretprobe *rp = *rp_ptr; + if (!rp) + return; + unregister_kretprobe(rp); + synchronize_rcu(); + kfree(rp); + *rp_ptr = NULL; +} + +static int syscall_regfunc_handler(struct kretprobe_instance *ri, struct pt_regs *regs) +{ + unsigned long flags; + spin_lock_irqsave(&tracepoint_reg_lock, flags); + if (tracepoint_reg_count < 1) { + // while install our tracepoint, mark our processes + ksu_mark_running_process_locked(); + } else if (tracepoint_reg_count == 1) { + // while other tracepoint first added, mark all processes + ksu_mark_all_process(); + } + tracepoint_reg_count++; + spin_unlock_irqrestore(&tracepoint_reg_lock, flags); + return 0; +} + +static int syscall_unregfunc_handler(struct kretprobe_instance *ri, struct pt_regs *regs) +{ + unsigned long flags; + spin_lock_irqsave(&tracepoint_reg_lock, flags); + tracepoint_reg_count--; + if (tracepoint_reg_count <= 0) { + // while no tracepoint left, unmark all processes + ksu_unmark_all_process(); + } else if (tracepoint_reg_count == 1) { + // while just our tracepoint left, unmark disallowed processes + ksu_mark_running_process_locked(); + } + spin_unlock_irqrestore(&tracepoint_reg_lock, flags); + return 0; +} + +static struct kretprobe *syscall_regfunc_rp = NULL; +static struct kretprobe *syscall_unregfunc_rp = NULL; +#endif + +static inline bool check_syscall_fastpath(int nr) +{ + switch (nr) { + case __NR_newfstatat: + case __NR_faccessat: + case __NR_execve: + case __NR_setresuid: + case __NR_clone: + case __NR_clone3: + return true; + default: + return false; + } +} + +// Unmark init's child that are not zygote, adbd or ksud +int ksu_handle_init_mark_tracker(const char __user **filename_user) +{ + char path[64]; + + if (unlikely(!filename_user)) + return 0; + + memset(path, 0, sizeof(path)); + strncpy_from_user_nofault(path, *filename_user, sizeof(path)); + + if (likely(strstr(path, "/app_process") == NULL && strstr(path, "/adbd") == NULL && strstr(path, "/ksud") == NULL)) { + pr_info("hook_manager: unmark %d exec %s", current->pid, path); + ksu_clear_task_tracepoint_flag_if_needed(current); + } + + return 0; +} +#ifdef CONFIG_KSU_MANUAL_SU +#include "manual_su.h" +static inline void ksu_handle_task_alloc(struct pt_regs *regs) +{ + ksu_try_escalate_for_uid(current_uid().val); +} +#endif + +#ifdef CONFIG_HAVE_SYSCALL_TRACEPOINTS +// Generic sys_enter handler that dispatches to specific handlers +static void ksu_sys_enter_handler(void *data, struct pt_regs *regs, long id) +{ + if (unlikely(check_syscall_fastpath(id))) { +#ifdef KSU_TP_HOOK + if (ksu_su_compat_enabled) { + // Handle newfstatat + if (id == __NR_newfstatat) { + int *dfd = (int *)&PT_REGS_PARM1(regs); + const char __user **filename_user = + (const char __user **)&PT_REGS_PARM2(regs); + int *flags = (int *)&PT_REGS_SYSCALL_PARM4(regs); + ksu_handle_stat(dfd, filename_user, flags); + return; + } + + // Handle faccessat + if (id == __NR_faccessat) { + int *dfd = (int *)&PT_REGS_PARM1(regs); + const char __user **filename_user = + (const char __user **)&PT_REGS_PARM2(regs); + int *mode = (int *)&PT_REGS_PARM3(regs); + ksu_handle_faccessat(dfd, filename_user, mode, NULL); + return; + } + + // Handle execve + if (id == __NR_execve) { + const char __user **filename_user = + (const char __user **)&PT_REGS_PARM1(regs); + if (current->pid != 1 && is_init(get_current_cred())) { + ksu_handle_init_mark_tracker(filename_user); + } else { + ksu_handle_execve_sucompat(filename_user, NULL, NULL, NULL); + } + return; + } + } +#endif + + // Handle setresuid + if (id == __NR_setresuid) { + uid_t ruid = (uid_t)PT_REGS_PARM1(regs); + uid_t euid = (uid_t)PT_REGS_PARM2(regs); + uid_t suid = (uid_t)PT_REGS_PARM3(regs); + ksu_handle_setresuid(ruid, euid, suid); + return; + } + +#ifdef CONFIG_KSU_MANUAL_SU + // Handle task_alloc via clone/fork + if (id == __NR_clone || id == __NR_clone3) + return ksu_handle_task_alloc(regs); +#endif + } +} +#endif + +void ksu_syscall_hook_manager_init(void) +{ + int ret; + pr_info("hook_manager: ksu_hook_manager_init called\n"); + +#ifdef CONFIG_KRETPROBES + // Register kretprobe for syscall_regfunc + syscall_regfunc_rp = init_kretprobe("syscall_regfunc", syscall_regfunc_handler); + // Register kretprobe for syscall_unregfunc + syscall_unregfunc_rp = init_kretprobe("syscall_unregfunc", syscall_unregfunc_handler); +#endif + +#ifdef CONFIG_HAVE_SYSCALL_TRACEPOINTS + ret = register_trace_sys_enter(ksu_sys_enter_handler, NULL); +#ifndef CONFIG_KRETPROBES + ksu_mark_running_process_locked(); +#endif + if (ret) { + pr_err("hook_manager: failed to register sys_enter tracepoint: %d\n", ret); + } else { + pr_info("hook_manager: sys_enter tracepoint registered\n"); + } +#endif + + ksu_setuid_hook_init(); + ksu_sucompat_init(); +} + +void ksu_syscall_hook_manager_exit(void) +{ + pr_info("hook_manager: ksu_hook_manager_exit called\n"); +#ifdef CONFIG_HAVE_SYSCALL_TRACEPOINTS + unregister_trace_sys_enter(ksu_sys_enter_handler, NULL); + tracepoint_synchronize_unregister(); + pr_info("hook_manager: sys_enter tracepoint unregistered\n"); +#endif + +#ifdef CONFIG_KRETPROBES + destroy_kretprobe(&syscall_regfunc_rp); + destroy_kretprobe(&syscall_unregfunc_rp); +#endif + + ksu_sucompat_exit(); + ksu_setuid_hook_exit(); +} diff --git a/kernel/syscall_hook_manager.h b/kernel/syscall_hook_manager.h new file mode 100644 index 0000000..90245c2 --- /dev/null +++ b/kernel/syscall_hook_manager.h @@ -0,0 +1,47 @@ +#ifndef __KSU_H_HOOK_MANAGER +#define __KSU_H_HOOK_MANAGER + +#include +#include +#include +#include +#include +#include +#include +#include "selinux/selinux.h" + +// Hook manager initialization and cleanup +void ksu_syscall_hook_manager_init(void); +void ksu_syscall_hook_manager_exit(void); + +// Process marking for tracepoint +void ksu_mark_all_process(void); +void ksu_unmark_all_process(void); +void ksu_mark_running_process(void); + +// Per-task mark operations +int ksu_get_task_mark(pid_t pid); +int ksu_set_task_mark(pid_t pid, bool mark); + + +static inline void ksu_set_task_tracepoint_flag(struct task_struct *t) +{ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) + set_task_syscall_work(t, SYSCALL_TRACEPOINT); +#else + set_tsk_thread_flag(t, TIF_SYSCALL_TRACEPOINT); +#endif +} + +static inline void ksu_clear_task_tracepoint_flag(struct task_struct *t) +{ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 11, 0) + clear_task_syscall_work(t, SYSCALL_TRACEPOINT); +#else + clear_tsk_thread_flag(t, TIF_SYSCALL_TRACEPOINT); +#endif +} + +void ksu_clear_task_tracepoint_flag_if_needed(struct task_struct *t); + +#endif diff --git a/kernel/throne_comm.c b/kernel/throne_comm.c new file mode 100644 index 0000000..90067dc --- /dev/null +++ b/kernel/throne_comm.c @@ -0,0 +1,214 @@ +#include +#include +#include +#include +#include +#include + +#include "klog.h" +#include "throne_comm.h" +#include "ksu.h" + +#define PROC_UID_SCANNER "ksu_uid_scanner" +#define UID_SCANNER_STATE_FILE "/data/adb/ksu/.uid_scanner" + +static struct proc_dir_entry *proc_entry = NULL; +static struct workqueue_struct *scanner_wq = NULL; +static struct work_struct scan_work; +static struct work_struct ksu_state_save_work; +static struct work_struct ksu_state_load_work; + + +// Signal userspace to rescan +static bool need_rescan = false; + +static void rescan_work_fn(struct work_struct *work) +{ + // Signal userspace through proc interface + need_rescan = true; + pr_info("requested userspace uid rescan\n"); +} + +void ksu_request_userspace_scan(void) +{ + if (scanner_wq) { + queue_work(scanner_wq, &scan_work); + } +} + +void ksu_handle_userspace_update(void) +{ + // Called when userspace notifies update complete + need_rescan = false; + pr_info("userspace uid list updated\n"); +} + +static void do_save_throne_state(struct work_struct *work) +{ + struct file *fp; + char state_char = ksu_uid_scanner_enabled ? '1' : '0'; + loff_t off = 0; + + fp = filp_open(UID_SCANNER_STATE_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (IS_ERR(fp)) { + pr_err("save_throne_state create file failed: %ld\n", PTR_ERR(fp)); + return; + } + + if (kernel_write(fp, &state_char, sizeof(state_char), &off) != sizeof(state_char)) { + pr_err("save_throne_state write failed\n"); + goto exit; + } + + pr_info("throne state saved: %s\n", ksu_uid_scanner_enabled ? "enabled" : "disabled"); + +exit: + filp_close(fp, 0); +} + +void do_load_throne_state(struct work_struct *work) +{ + struct file *fp; + char state_char; + loff_t off = 0; + ssize_t ret; + + fp = filp_open(UID_SCANNER_STATE_FILE, O_RDONLY, 0); + if (IS_ERR(fp)) { + pr_info("throne state file not found, using default: disabled\n"); + ksu_uid_scanner_enabled = false; + return; + } + + ret = kernel_read(fp, &state_char, sizeof(state_char), &off); + if (ret != sizeof(state_char)) { + pr_err("load_throne_state read err: %zd\n", ret); + ksu_uid_scanner_enabled = false; + goto exit; + } + + ksu_uid_scanner_enabled = (state_char == '1'); + pr_info("throne state loaded: %s\n", ksu_uid_scanner_enabled ? "enabled" : "disabled"); + +exit: + filp_close(fp, 0); +} + +bool ksu_throne_comm_load_state(void) +{ + return ksu_queue_work(&ksu_state_load_work); +} + +void ksu_throne_comm_save_state(void) +{ + ksu_queue_work(&ksu_state_save_work); +} + +static int uid_scanner_show(struct seq_file *m, void *v) +{ + if (need_rescan) { + seq_puts(m, "RESCAN\n"); + } else { + seq_puts(m, "OK\n"); + } + return 0; +} + +static int uid_scanner_open(struct inode *inode, struct file *file) +{ + return single_open(file, uid_scanner_show, NULL); +} + +static ssize_t uid_scanner_write(struct file *file, const char __user *buffer, + size_t count, loff_t *pos) +{ + char cmd[16]; + + if (count >= sizeof(cmd)) + return -EINVAL; + + if (copy_from_user(cmd, buffer, count)) + return -EFAULT; + + cmd[count] = '\0'; + + // Remove newline if present + if (count > 0 && cmd[count-1] == '\n') + cmd[count-1] = '\0'; + + if (strcmp(cmd, "UPDATED") == 0) { + ksu_handle_userspace_update(); + pr_info("received userspace update notification\n"); + } + + return count; +} + +#ifdef KSU_COMPAT_HAS_PROC_OPS +static const struct proc_ops uid_scanner_proc_ops = { + .proc_open = uid_scanner_open, + .proc_read = seq_read, + .proc_write = uid_scanner_write, + .proc_lseek = seq_lseek, + .proc_release = single_release, +}; +#else +static const struct file_operations uid_scanner_proc_ops = { + .owner = THIS_MODULE, + .open = uid_scanner_open, + .read = seq_read, + .write = uid_scanner_write, + .llseek = seq_lseek, + .release = single_release, +}; +#endif + +int ksu_throne_comm_init(void) +{ + // Create workqueue + scanner_wq = alloc_workqueue("ksu_scanner", WQ_UNBOUND, 1); + if (!scanner_wq) { + pr_err("failed to create scanner workqueue\n"); + return -ENOMEM; + } + + INIT_WORK(&scan_work, rescan_work_fn); + + // Create proc entry + proc_entry = proc_create(PROC_UID_SCANNER, 0600, NULL, &uid_scanner_proc_ops); + if (!proc_entry) { + pr_err("failed to create proc entry\n"); + destroy_workqueue(scanner_wq); + return -ENOMEM; + } + + pr_info("throne communication initialized\n"); + return 0; +} + +void ksu_throne_comm_exit(void) +{ + if (proc_entry) { + proc_remove(proc_entry); + proc_entry = NULL; + } + + if (scanner_wq) { + destroy_workqueue(scanner_wq); + scanner_wq = NULL; + } + + pr_info("throne communication cleaned up\n"); +} + +int ksu_uid_init(void) +{ + INIT_WORK(&ksu_state_save_work, do_save_throne_state); + INIT_WORK(&ksu_state_load_work, do_load_throne_state); + return 0; +} + +void ksu_uid_exit(void) +{ + do_save_throne_state(NULL); +} \ No newline at end of file diff --git a/kernel/throne_comm.h b/kernel/throne_comm.h new file mode 100644 index 0000000..4deba2a --- /dev/null +++ b/kernel/throne_comm.h @@ -0,0 +1,22 @@ +#ifndef __KSU_H_THRONE_COMM +#define __KSU_H_THRONE_COMM + +void ksu_request_userspace_scan(void); + +void ksu_handle_userspace_update(void); + +int ksu_throne_comm_init(void); + +void ksu_throne_comm_exit(void); + +int ksu_uid_init(void); + +void ksu_uid_exit(void); + +bool ksu_throne_comm_load_state(void); + +void ksu_throne_comm_save_state(void); + +void do_load_throne_state(struct work_struct *work); + +#endif \ No newline at end of file diff --git a/kernel/throne_tracker.c b/kernel/throne_tracker.c new file mode 100644 index 0000000..eb8a0b3 --- /dev/null +++ b/kernel/throne_tracker.c @@ -0,0 +1,567 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "allowlist.h" +#include "klog.h" // IWYU pragma: keep +#include "manager.h" +#include "throne_tracker.h" +#include "apk_sign.h" +#include "dynamic_manager.h" +#include "throne_comm.h" + +uid_t ksu_manager_uid = KSU_INVALID_UID; +static uid_t locked_manager_uid = KSU_INVALID_UID; +static uid_t locked_dynamic_manager_uid = KSU_INVALID_UID; + +#define KSU_UID_LIST_PATH "/data/misc/user_uid/uid_list" +#define SYSTEM_PACKAGES_LIST_PATH "/data/system/packages.list" + +struct uid_data { + struct list_head list; + u32 uid; + char package[KSU_MAX_PACKAGE_NAME]; +}; + +// Try read /data/misc/user_uid/uid_list +static int uid_from_um_list(struct list_head *uid_list) +{ + struct file *fp; + char *buf = NULL; + loff_t size, pos = 0; + ssize_t nr; + int cnt = 0; + + fp = filp_open(KSU_UID_LIST_PATH, O_RDONLY, 0); + if (IS_ERR(fp)) + return -ENOENT; + + size = fp->f_inode->i_size; + if (size <= 0) { + filp_close(fp, NULL); + return -ENODATA; + } + + buf = kzalloc(size + 1, GFP_ATOMIC); + if (!buf) { + pr_err("uid_list: OOM %lld B\n", size); + filp_close(fp, NULL); + return -ENOMEM; + } + + nr = kernel_read(fp, buf, size, &pos); + filp_close(fp, NULL); + if (nr != size) { + pr_err("uid_list: short read %zd/%lld\n", nr, size); + kfree(buf); + return -EIO; + } + buf[size] = '\0'; + + for (char *line = buf, *next; line; line = next) { + next = strchr(line, '\n'); + if (next) *next++ = '\0'; + + while (*line == ' ' || *line == '\t' || *line == '\r') ++line; + if (!*line) continue; + + char *uid_str = strsep(&line, " \t"); + char *pkg = line; + if (!pkg) continue; + while (*pkg == ' ' || *pkg == '\t') ++pkg; + if (!*pkg) continue; + + u32 uid; + if (kstrtou32(uid_str, 10, &uid)) { + pr_warn_once("uid_list: bad uid <%s>\n", uid_str); + continue; + } + + struct uid_data *d = kzalloc(sizeof(*d), GFP_ATOMIC); + if (unlikely(!d)) { + pr_err("uid_list: OOM uid=%u\n", uid); + continue; + } + + d->uid = uid; + strscpy(d->package, pkg, KSU_MAX_PACKAGE_NAME); + list_add_tail(&d->list, uid_list); + ++cnt; + } + + kfree(buf); + pr_info("uid_list: loaded %d entries\n", cnt); + return cnt > 0 ? 0 : -ENODATA; +} + +static int get_pkg_from_apk_path(char *pkg, const char *path) +{ + int len = strlen(path); + if (len >= KSU_MAX_PACKAGE_NAME || len < 1) + return -1; + + const char *last_slash = NULL; + const char *second_last_slash = NULL; + + int i; + for (i = len - 1; i >= 0; i--) { + if (path[i] == '/') { + if (!last_slash) { + last_slash = &path[i]; + } else { + second_last_slash = &path[i]; + break; + } + } + } + + if (!last_slash || !second_last_slash) + return -1; + + const char *last_hyphen = strchr(second_last_slash, '-'); + if (!last_hyphen || last_hyphen > last_slash) + return -1; + + int pkg_len = last_hyphen - second_last_slash - 1; + if (pkg_len >= KSU_MAX_PACKAGE_NAME || pkg_len <= 0) + return -1; + + // Copying the package name + strncpy(pkg, second_last_slash + 1, pkg_len); + pkg[pkg_len] = '\0'; + + return 0; +} + +static void crown_manager(const char *apk, struct list_head *uid_data, int signature_index) +{ + char pkg[KSU_MAX_PACKAGE_NAME]; + if (get_pkg_from_apk_path(pkg, apk) < 0) { + pr_err("Failed to get package name from apk path: %s\n", apk); + return; + } + + pr_info("manager pkg: %s, signature_index: %d\n", pkg, signature_index); + +#ifdef KSU_MANAGER_PACKAGE + // pkg is `/` + if (strncmp(pkg, KSU_MANAGER_PACKAGE, sizeof(KSU_MANAGER_PACKAGE))) { + pr_info("manager package is inconsistent with kernel build: %s\n", + KSU_MANAGER_PACKAGE); + return; + } +#endif + struct uid_data *np; + + list_for_each_entry(np, uid_data, list) { + if (strncmp(np->package, pkg, KSU_MAX_PACKAGE_NAME) == 0) { + bool is_dynamic = (signature_index == DYNAMIC_SIGN_INDEX || signature_index >= 2); + + if (is_dynamic) { + if (locked_dynamic_manager_uid != KSU_INVALID_UID && locked_dynamic_manager_uid != np->uid) { + pr_info("Unlocking previous dynamic manager UID: %d\n", locked_dynamic_manager_uid); + ksu_remove_manager(locked_dynamic_manager_uid); + locked_dynamic_manager_uid = KSU_INVALID_UID; + } + } else { + if (locked_manager_uid != KSU_INVALID_UID && locked_manager_uid != np->uid) { + pr_info("Unlocking previous manager UID: %d\n", locked_manager_uid); + ksu_invalidate_manager_uid(); // unlock old one + locked_manager_uid = KSU_INVALID_UID; + } + } + + pr_info("Crowning %s manager: %s (uid=%d, signature_index=%d)\n", + is_dynamic ? "dynamic" : "traditional", pkg, np->uid, signature_index); + + if (is_dynamic) { + ksu_add_manager(np->uid, signature_index); + locked_dynamic_manager_uid = np->uid; + + // If there is no traditional manager, set it to the current UID + if (!ksu_is_manager_uid_valid()) { + ksu_set_manager_uid(np->uid); + locked_manager_uid = np->uid; + } + } else { + ksu_set_manager_uid(np->uid); // throne new UID + locked_manager_uid = np->uid; // store locked UID + } + break; + } + } +} + +#define DATA_PATH_LEN 384 // 384 is enough for /data/app//base.apk + +struct data_path { + char dirpath[DATA_PATH_LEN]; + int depth; + struct list_head list; +}; + +struct apk_path_hash { + unsigned int hash; + bool exists; + struct list_head list; +}; + +static struct list_head apk_path_hash_list = LIST_HEAD_INIT(apk_path_hash_list); + +struct my_dir_context { + struct dir_context ctx; + struct list_head *data_path_list; + char *parent_dir; + void *private_data; + int depth; + int *stop; +}; +// https://docs.kernel.org/filesystems/porting.html +// filldir_t (readdir callbacks) calling conventions have changed. Instead of returning 0 or -E... it returns bool now. false means "no more" (as -E... used to) and true - "keep going" (as 0 in old calling conventions). Rationale: callers never looked at specific -E... values anyway. -> iterate_shared() instances require no changes at all, all filldir_t ones in the tree converted. +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 1, 0) +#define FILLDIR_RETURN_TYPE bool +#define FILLDIR_ACTOR_CONTINUE true +#define FILLDIR_ACTOR_STOP false +#else +#define FILLDIR_RETURN_TYPE int +#define FILLDIR_ACTOR_CONTINUE 0 +#define FILLDIR_ACTOR_STOP -EINVAL +#endif +FILLDIR_RETURN_TYPE my_actor(struct dir_context *ctx, const char *name, + int namelen, loff_t off, u64 ino, + unsigned int d_type) +{ + struct my_dir_context *my_ctx = + container_of(ctx, struct my_dir_context, ctx); + char dirpath[DATA_PATH_LEN]; + + if (!my_ctx) { + pr_err("Invalid context\n"); + return FILLDIR_ACTOR_STOP; + } + if (my_ctx->stop && *my_ctx->stop) { + pr_info("Stop searching\n"); + return FILLDIR_ACTOR_STOP; + } + + if (!strncmp(name, "..", namelen) || !strncmp(name, ".", namelen)) + return FILLDIR_ACTOR_CONTINUE; // Skip "." and ".." + + if (d_type == DT_DIR && namelen >= 8 && !strncmp(name, "vmdl", 4) && + !strncmp(name + namelen - 4, ".tmp", 4)) { + pr_info("Skipping directory: %.*s\n", namelen, name); + return FILLDIR_ACTOR_CONTINUE; // Skip staging package + } + + if (snprintf(dirpath, DATA_PATH_LEN, "%s/%.*s", my_ctx->parent_dir, + namelen, name) >= DATA_PATH_LEN) { + pr_err("Path too long: %s/%.*s\n", my_ctx->parent_dir, namelen, + name); + return FILLDIR_ACTOR_CONTINUE; + } + + if (d_type == DT_DIR && my_ctx->depth > 0 && + (my_ctx->stop && !*my_ctx->stop)) { + struct data_path *data = kzalloc(sizeof(struct data_path), GFP_ATOMIC); + + if (!data) { + pr_err("Failed to allocate memory for %s\n", dirpath); + return FILLDIR_ACTOR_CONTINUE; + } + + strscpy(data->dirpath, dirpath, DATA_PATH_LEN); + data->depth = my_ctx->depth - 1; + list_add_tail(&data->list, my_ctx->data_path_list); + } else { + if ((namelen == 8) && (strncmp(name, "base.apk", namelen) == 0)) { + struct apk_path_hash *pos, *n; +#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 8, 0) + unsigned int hash = full_name_hash(dirpath, strlen(dirpath)); +#else + unsigned int hash = full_name_hash(NULL, dirpath, strlen(dirpath)); +#endif + list_for_each_entry(pos, &apk_path_hash_list, list) { + if (hash == pos->hash) { + pos->exists = true; + return FILLDIR_ACTOR_CONTINUE; + } + } + + int signature_index = -1; + bool is_multi_manager = is_dynamic_manager_apk( + dirpath, &signature_index); + + pr_info("Found new base.apk at path: %s, is_multi_manager: %d, signature_index: %d\n", + dirpath, is_multi_manager, signature_index); + + // Check for dynamic sign or multi-manager signatures + if (is_multi_manager && (signature_index == DYNAMIC_SIGN_INDEX || signature_index >= 2)) { + crown_manager(dirpath, my_ctx->private_data, signature_index); + } else if (is_manager_apk(dirpath)) { + crown_manager(dirpath, my_ctx->private_data, 0); + *my_ctx->stop = 1; + } + + struct apk_path_hash *apk_data = kzalloc(sizeof(*apk_data), GFP_ATOMIC); + if (apk_data) { + apk_data->hash = hash; + apk_data->exists = true; + list_add_tail(&apk_data->list, &apk_path_hash_list); + } + + if (is_manager_apk(dirpath)) { + // Manager found, clear APK cache list + list_for_each_entry_safe(pos, n, &apk_path_hash_list, list) { + list_del(&pos->list); + kfree(pos); + } + } + } + } + + return FILLDIR_ACTOR_CONTINUE; +} + +void search_manager(const char *path, int depth, struct list_head *uid_data) +{ + int i, stop = 0; + struct list_head data_path_list; + INIT_LIST_HEAD(&data_path_list); + unsigned long data_app_magic = 0; + + // Initialize APK cache list + struct apk_path_hash *pos, *n; + list_for_each_entry (pos, &apk_path_hash_list, list) { + pos->exists = false; + } + + // First depth + struct data_path data; + strscpy(data.dirpath, path, DATA_PATH_LEN); + data.depth = depth; + list_add_tail(&data.list, &data_path_list); + + for (i = depth; i >= 0; i--) { + struct data_path *pos, *n; + + list_for_each_entry_safe (pos, n, &data_path_list, list) { + struct my_dir_context ctx = { .ctx.actor = my_actor, + .data_path_list = &data_path_list, + .parent_dir = pos->dirpath, + .private_data = uid_data, + .depth = pos->depth, + .stop = &stop }; + struct file *file; + + if (!stop) { + file = filp_open(pos->dirpath, O_RDONLY | O_NOFOLLOW, 0); + if (IS_ERR(file)) { + pr_err("Failed to open directory: %s, err: %ld\n", + pos->dirpath, PTR_ERR(file)); + goto skip_iterate; + } + + // grab magic on first folder, which is /data/app + if (!data_app_magic) { + if (file->f_inode->i_sb->s_magic) { + data_app_magic = file->f_inode->i_sb->s_magic; + pr_info("%s: dir: %s got magic! 0x%lx\n", __func__, + pos->dirpath, data_app_magic); + } else { + filp_close(file, NULL); + goto skip_iterate; + } + } + + if (file->f_inode->i_sb->s_magic != data_app_magic) { + pr_info("%s: skip: %s magic: 0x%lx expected: 0x%lx\n", + __func__, pos->dirpath, + file->f_inode->i_sb->s_magic, data_app_magic); + filp_close(file, NULL); + goto skip_iterate; + } + + iterate_dir(file, &ctx.ctx); + filp_close(file, NULL); + } + skip_iterate: + list_del(&pos->list); + if (pos != &data) + kfree(pos); + } + } + + // Remove stale cached APK entries + list_for_each_entry_safe (pos, n, &apk_path_hash_list, list) { + if (!pos->exists) { + list_del(&pos->list); + kfree(pos); + } + } +} + +static bool is_uid_exist(uid_t uid, char *package, void *data) +{ + struct list_head *list = (struct list_head *)data; + struct uid_data *np; + + bool exist = false; + list_for_each_entry (np, list, list) { + if (np->uid == uid % 100000 && + strncmp(np->package, package, KSU_MAX_PACKAGE_NAME) == 0) { + exist = true; + break; + } + } + return exist; +} + +void track_throne(bool prune_only) +{ + struct list_head uid_list; + struct uid_data *np, *n; + struct file *fp; + char chr = 0; + loff_t pos = 0; + loff_t line_start = 0; + char buf[KSU_MAX_PACKAGE_NAME]; + static bool manager_exist = false; + static bool dynamic_manager_exist = false; + int current_manager_uid = ksu_get_manager_uid() % 100000; + + // init uid list head + INIT_LIST_HEAD(&uid_list); + + if (ksu_uid_scanner_enabled) { + pr_info("Scanning %s directory..\n", KSU_UID_LIST_PATH); + + if (uid_from_um_list(&uid_list) == 0) { + pr_info("Loaded UIDs from %s success\n", KSU_UID_LIST_PATH); + goto uid_ready; + } + + pr_warn("%s read failed, fallback to %s\n", + KSU_UID_LIST_PATH, SYSTEM_PACKAGES_LIST_PATH); + } + + { + fp = filp_open(SYSTEM_PACKAGES_LIST_PATH, O_RDONLY, 0); + if (IS_ERR(fp)) { + pr_err("%s: open " SYSTEM_PACKAGES_LIST_PATH " failed: %ld\n", __func__, PTR_ERR(fp)); + return; + } + + for (;;) { + ssize_t count = + kernel_read(fp, &chr, sizeof(chr), &pos); + if (count != sizeof(chr)) + break; + if (chr != '\n') + continue; + + count = kernel_read(fp, buf, sizeof(buf), + &line_start); + struct uid_data *data = + kzalloc(sizeof(struct uid_data), GFP_ATOMIC); + if (!data) { + filp_close(fp, 0); + goto out; + } + + char *tmp = buf; + const char *delim = " "; + char *package = strsep(&tmp, delim); + char *uid = strsep(&tmp, delim); + if (!uid || !package) { + pr_err("update_uid: package or uid is NULL!\n"); + break; + } + + u32 res; + if (kstrtou32(uid, 10, &res)) { + pr_err("update_uid: uid parse err\n"); + break; + } + data->uid = res; + strncpy(data->package, package, KSU_MAX_PACKAGE_NAME); + list_add_tail(&data->list, &uid_list); + // reset line start + line_start = pos; + } + + filp_close(fp, 0); + } + +uid_ready: + if (prune_only) + goto prune; + + // first, check if manager_uid exist! + list_for_each_entry(np, &uid_list, list) { + if (np->uid == current_manager_uid) { + manager_exist = true; + break; + } + } + + if (!manager_exist && locked_manager_uid != KSU_INVALID_UID) { + pr_info("Manager APK removed, unlock previous UID: %d\n", + locked_manager_uid); + ksu_invalidate_manager_uid(); + locked_manager_uid = KSU_INVALID_UID; + } + + // Check if the Dynamic Manager exists (only check locked UIDs) + if (ksu_is_dynamic_manager_enabled() && + locked_dynamic_manager_uid != KSU_INVALID_UID) { + list_for_each_entry(np, &uid_list, list) { + if (np->uid == locked_dynamic_manager_uid) { + dynamic_manager_exist = true; + break; + } + } + + if (!dynamic_manager_exist) { + pr_info("Dynamic manager APK removed, unlock previous UID: %d\n", + locked_dynamic_manager_uid); + ksu_remove_manager(locked_dynamic_manager_uid); + locked_dynamic_manager_uid = KSU_INVALID_UID; + } + } + + bool need_search = !manager_exist; + if (ksu_is_dynamic_manager_enabled() && !dynamic_manager_exist) + need_search = true; + + if (need_search) { + pr_info("Searching for manager(s)...\n"); + search_manager("/data/app", 2, &uid_list); + pr_info("Manager search finished\n"); + } + +prune: + // then prune the allowlist + ksu_prune_allowlist(is_uid_exist, &uid_list); +out: + // free uid_list + list_for_each_entry_safe(np, n, &uid_list, list) { + list_del(&np->list); + kfree(np); + } +} + +void ksu_throne_tracker_init(void) +{ + // nothing to do +} + +void ksu_throne_tracker_exit(void) +{ + // nothing to do +} \ No newline at end of file diff --git a/kernel/throne_tracker.h b/kernel/throne_tracker.h new file mode 100644 index 0000000..6be7d5f --- /dev/null +++ b/kernel/throne_tracker.h @@ -0,0 +1,10 @@ +#ifndef __KSU_H_UID_OBSERVER +#define __KSU_H_UID_OBSERVER + +void ksu_throne_tracker_init(); + +void ksu_throne_tracker_exit(); + +void track_throne(bool prune_only); + +#endif diff --git a/kernel/umount_manager.c b/kernel/umount_manager.c new file mode 100644 index 0000000..31e45b1 --- /dev/null +++ b/kernel/umount_manager.c @@ -0,0 +1,242 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "klog.h" +#include "kernel_umount.h" +#include "umount_manager.h" + +static struct umount_manager g_umount_mgr = { + .entry_count = 0, + .max_entries = 64, +}; + +static void try_umount_path(struct umount_entry *entry) +{ + try_umount(entry->path, entry->flags); +} + +static struct umount_entry *find_entry_locked(const char *path) +{ + struct umount_entry *entry; + + list_for_each_entry(entry, &g_umount_mgr.entry_list, list) { + if (strcmp(entry->path, path) == 0) { + return entry; + } + } + + return NULL; +} + +int ksu_umount_manager_init(void) +{ + INIT_LIST_HEAD(&g_umount_mgr.entry_list); + spin_lock_init(&g_umount_mgr.lock); + + return 0; +} + +void ksu_umount_manager_exit(void) +{ + struct umount_entry *entry, *tmp; + unsigned long flags; + + spin_lock_irqsave(&g_umount_mgr.lock, flags); + + list_for_each_entry_safe(entry, tmp, &g_umount_mgr.entry_list, list) { + list_del(&entry->list); + kfree(entry); + g_umount_mgr.entry_count--; + } + + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); + + pr_info("Umount manager cleaned up\n"); +} + +int ksu_umount_manager_add(const char *path, int flags, bool is_default) +{ + struct umount_entry *entry; + unsigned long irqflags; + int ret = 0; + + if (flags == -1) + flags = MNT_DETACH; + + if (!path || strlen(path) == 0 || strlen(path) >= 256) { + return -EINVAL; + } + + spin_lock_irqsave(&g_umount_mgr.lock, irqflags); + + if (g_umount_mgr.entry_count >= g_umount_mgr.max_entries) { + pr_err("Umount manager: max entries reached\n"); + ret = -ENOMEM; + goto out; + } + + if (find_entry_locked(path)) { + pr_warn("Umount manager: path already exists: %s\n", path); + ret = -EEXIST; + goto out; + } + + entry = kzalloc(sizeof(*entry), GFP_ATOMIC); + if (!entry) { + ret = -ENOMEM; + goto out; + } + + strncpy(entry->path, path, sizeof(entry->path) - 1); + entry->flags = flags; + entry->state = UMOUNT_STATE_IDLE; + entry->is_default = is_default; + entry->ref_count = 0; + + list_add_tail(&entry->list, &g_umount_mgr.entry_list); + g_umount_mgr.entry_count++; + + pr_info("Umount manager: added %s entry: %s\n", + is_default ? "default" : "custom", path); + +out: + spin_unlock_irqrestore(&g_umount_mgr.lock, irqflags); + return ret; +} + +int ksu_umount_manager_remove(const char *path) +{ + struct umount_entry *entry; + unsigned long flags; + int ret = 0; + + if (!path) { + return -EINVAL; + } + + spin_lock_irqsave(&g_umount_mgr.lock, flags); + + entry = find_entry_locked(path); + if (!entry) { + ret = -ENOENT; + goto out; + } + + if (entry->is_default) { + pr_err("Umount manager: cannot remove default entry: %s\n", path); + ret = -EPERM; + goto out; + } + + if (entry->state == UMOUNT_STATE_BUSY || entry->ref_count > 0) { + pr_err("Umount manager: entry is busy: %s\n", path); + ret = -EBUSY; + goto out; + } + + list_del(&entry->list); + g_umount_mgr.entry_count--; + kfree(entry); + + pr_info("Umount manager: removed entry: %s\n", path); + +out: + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); + return ret; +} + +void ksu_umount_manager_execute_all(const struct cred *cred) +{ + struct umount_entry *entry; + unsigned long flags; + + spin_lock_irqsave(&g_umount_mgr.lock, flags); + + list_for_each_entry(entry, &g_umount_mgr.entry_list, list) { + if (entry->state == UMOUNT_STATE_IDLE) { + entry->ref_count++; + } + } + + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); + + list_for_each_entry(entry, &g_umount_mgr.entry_list, list) { + if (entry->ref_count > 0 && entry->state == UMOUNT_STATE_IDLE) { + try_umount_path(entry); + } + } + + spin_lock_irqsave(&g_umount_mgr.lock, flags); + + list_for_each_entry(entry, &g_umount_mgr.entry_list, list) { + if (entry->ref_count > 0) { + entry->ref_count--; + } + } + + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); +} + +int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count) +{ + struct umount_entry *entry; + struct ksu_umount_entry_info info; + unsigned long flags; + u32 idx = 0; + u32 max_count = *count; + + spin_lock_irqsave(&g_umount_mgr.lock, flags); + + list_for_each_entry(entry, &g_umount_mgr.entry_list, list) { + if (idx >= max_count) { + break; + } + + memset(&info, 0, sizeof(info)); + strncpy(info.path, entry->path, sizeof(info.path) - 1); + info.flags = entry->flags; + info.is_default = entry->is_default; + info.state = entry->state; + info.ref_count = entry->ref_count; + + if (copy_to_user(&entries[idx], &info, sizeof(info))) { + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); + return -EFAULT; + } + + idx++; + } + + *count = idx; + + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); + return 0; +} + +int ksu_umount_manager_clear_custom(void) +{ + struct umount_entry *entry, *tmp; + unsigned long flags; + u32 cleared = 0; + + spin_lock_irqsave(&g_umount_mgr.lock, flags); + + list_for_each_entry_safe(entry, tmp, &g_umount_mgr.entry_list, list) { + if (!entry->is_default && entry->state == UMOUNT_STATE_IDLE && entry->ref_count == 0) { + list_del(&entry->list); + kfree(entry); + g_umount_mgr.entry_count--; + cleared++; + } + } + + spin_unlock_irqrestore(&g_umount_mgr.lock, flags); + + pr_info("Umount manager: cleared %u custom entries\n", cleared); + return 0; +} diff --git a/kernel/umount_manager.h b/kernel/umount_manager.h new file mode 100644 index 0000000..41c9888 --- /dev/null +++ b/kernel/umount_manager.h @@ -0,0 +1,63 @@ +#ifndef __KSU_H_UMOUNT_MANAGER +#define __KSU_H_UMOUNT_MANAGER + +#include +#include +#include + +struct cred; + +enum umount_entry_state { + UMOUNT_STATE_IDLE = 0, + UMOUNT_STATE_ACTIVE = 1, + UMOUNT_STATE_BUSY = 2, +}; + +struct umount_entry { + struct list_head list; + char path[256]; + int flags; + enum umount_entry_state state; + bool is_default; + u32 ref_count; +}; + +struct umount_manager { + struct list_head entry_list; + spinlock_t lock; + u32 entry_count; + u32 max_entries; +}; + +enum umount_manager_op { + UMOUNT_OP_ADD = 0, + UMOUNT_OP_REMOVE = 1, + UMOUNT_OP_LIST = 2, + UMOUNT_OP_CLEAR_CUSTOM = 3, +}; + +struct ksu_umount_manager_cmd { + __u32 operation; + char path[256]; + __s32 flags; + __u32 count; + __aligned_u64 entries_ptr; +}; + +struct ksu_umount_entry_info { + char path[256]; + __s32 flags; + __u8 is_default; + __u32 state; + __u32 ref_count; +}; + +int ksu_umount_manager_init(void); +void ksu_umount_manager_exit(void); +int ksu_umount_manager_add(const char *path, int flags, bool is_default); +int ksu_umount_manager_remove(const char *path); +void ksu_umount_manager_execute_all(const struct cred *cred); +int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count); +int ksu_umount_manager_clear_custom(void); + +#endif // __KSU_H_UMOUNT_MANAGER diff --git a/manager/.gitignore b/manager/.gitignore new file mode 100644 index 0000000..a595ddf --- /dev/null +++ b/manager/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +.idea +.kotlin +.DS_Store +build +captures +.cxx +local.properties +key.jks diff --git a/manager/app/.gitignore b/manager/app/.gitignore new file mode 100644 index 0000000..dc5ca96 --- /dev/null +++ b/manager/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release/ diff --git a/manager/app/build.gradle.kts b/manager/app/build.gradle.kts new file mode 100644 index 0000000..d51afa9 --- /dev/null +++ b/manager/app/build.gradle.kts @@ -0,0 +1,168 @@ +@file:Suppress("UnstableApiUsage") + +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import com.android.build.gradle.tasks.PackageAndroidArtifact + +plugins { + alias(libs.plugins.agp.app) + alias(libs.plugins.kotlin) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ksp) + alias(libs.plugins.lsplugin.apksign) + id("kotlin-parcelize") + + +} + +val managerVersionCode: Int by rootProject.extra +val managerVersionName: String by rootProject.extra +val androidCmakeVersion: String by rootProject.extra + +apksign { + storeFileProperty = "KEYSTORE_FILE" + storePasswordProperty = "KEYSTORE_PASSWORD" + keyAliasProperty = "KEY_ALIAS" + keyPasswordProperty = "KEY_PASSWORD" +} + + +android { + + /**signingConfigs { + create("Debug") { + storeFile = file("D:\\other\\AndroidTool\\android_key\\keystore\\release-key.keystore") + storePassword = "" + keyAlias = "" + keyPassword = "" + } + }**/ + namespace = "com.sukisu.ultra" + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + vcsInfo.include = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + /**debug { + signingConfig = signingConfigs.named("Debug").get() as ApkSigningConfig + }**/ + } + + buildFeatures { + aidl = true + buildConfig = true + compose = true + prefab = true + } + + packaging { + jniLibs { + useLegacyPackaging = true + } + resources { + // https://stackoverflow.com/a/58956288 + // It will break Layout Inspector, but it's unused for release build. + excludes += "META-INF/*.version" + // https://github.com/Kotlin/kotlinx.coroutines?tab=readme-ov-file#avoiding-including-the-debug-infrastructure-in-the-resulting-apk + excludes += "DebugProbesKt.bin" + // https://issueantenna.com/repo/kotlin/kotlinx.coroutines/issues/3158 + excludes += "kotlin-tooling-metadata.json" + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = androidCmakeVersion + } + } + + applicationVariants.all { + outputs.forEach { + val output = it as BaseVariantOutputImpl + output.outputFileName = "SukiSU_${managerVersionName}_${managerVersionCode}-$name.apk" + } + kotlin.sourceSets { + getByName(name) { + kotlin.srcDir("build/generated/ksp/$name/kotlin") + } + } + } + + // https://stackoverflow.com/a/77745844 + tasks.withType { + doFirst { appMetadata.asFile.orNull?.writeText("") } + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + + androidResources { + generateLocaleConfig = true + } +} + +ksp { + arg("compose-destinations.defaultTransitions", "none") +} + +dependencies { + implementation(libs.gson) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.foundation) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.compose.foundation) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(libs.compose.destinations.core) + ksp(libs.compose.destinations.ksp) + + implementation(libs.com.github.topjohnwu.libsu.core) + implementation(libs.com.github.topjohnwu.libsu.service) + implementation(libs.com.github.topjohnwu.libsu.io) + + implementation(libs.dev.rikka.rikkax.parcelablelist) + + implementation(libs.io.coil.kt.coil.compose) + + implementation(libs.kotlinx.coroutines.core) + + implementation(libs.me.zhanghai.android.appiconloader.coil) + + implementation(libs.sheet.compose.dialogs.core) + implementation(libs.sheet.compose.dialogs.list) + implementation(libs.sheet.compose.dialogs.input) + + implementation(libs.markdown) + implementation(libs.androidx.webkit) + + implementation(libs.lsposed.cxx) + + implementation(libs.com.github.topjohnwu.libsu.core) + + implementation(libs.mmrl.platform) + compileOnly(libs.mmrl.hidden.api) + implementation(libs.mmrl.webui) + implementation(libs.mmrl.ui) + + implementation(libs.accompanist.drawablepainter) + +} \ No newline at end of file diff --git a/manager/app/proguard-rules.pro b/manager/app/proguard-rules.pro new file mode 100644 index 0000000..18c49c1 --- /dev/null +++ b/manager/app/proguard-rules.pro @@ -0,0 +1,48 @@ +-verbose +-optimizationpasses 5 + +-dontwarn org.conscrypt.** +-dontwarn kotlinx.serialization.** + +# Please add these rules to your existing keep rules in order to suppress warnings. +# This is generated automatically by the Android Gradle plugin. +-dontwarn com.google.auto.service.AutoService +-dontwarn com.google.j2objc.annotations.RetainedWith +-dontwarn javax.lang.model.SourceVersion +-dontwarn javax.lang.model.element.AnnotationMirror +-dontwarn javax.lang.model.element.AnnotationValue +-dontwarn javax.lang.model.element.Element +-dontwarn javax.lang.model.element.ElementKind +-dontwarn javax.lang.model.element.ElementVisitor +-dontwarn javax.lang.model.element.ExecutableElement +-dontwarn javax.lang.model.element.Modifier +-dontwarn javax.lang.model.element.Name +-dontwarn javax.lang.model.element.PackageElement +-dontwarn javax.lang.model.element.TypeElement +-dontwarn javax.lang.model.element.TypeParameterElement +-dontwarn javax.lang.model.element.VariableElement +-dontwarn javax.lang.model.type.ArrayType +-dontwarn javax.lang.model.type.DeclaredType +-dontwarn javax.lang.model.type.ExecutableType +-dontwarn javax.lang.model.type.TypeKind +-dontwarn javax.lang.model.type.TypeMirror +-dontwarn javax.lang.model.type.TypeVariable +-dontwarn javax.lang.model.type.TypeVisitor +-dontwarn javax.lang.model.util.AbstractAnnotationValueVisitor8 +-dontwarn javax.lang.model.util.AbstractTypeVisitor8 +-dontwarn javax.lang.model.util.ElementFilter +-dontwarn javax.lang.model.util.Elements +-dontwarn javax.lang.model.util.SimpleElementVisitor8 +-dontwarn javax.lang.model.util.SimpleTypeVisitor7 +-dontwarn javax.lang.model.util.SimpleTypeVisitor8 +-dontwarn javax.lang.model.util.Types +-dontwarn javax.tools.Diagnostic$Kind + + +# MMRL:webui reflection +-keep class com.dergoogler.mmrl.webui.interfaces.** { *; } +-keep class com.sukisu.ultra.ui.webui.WebViewInterface { *; } + +-keep,allowobfuscation class * extends com.dergoogler.mmrl.platform.content.IService { *; } + +-keep interface com.sukisu.zako.** { *; } \ No newline at end of file diff --git a/manager/app/src/main/AndroidManifest.xml b/manager/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d4f5d53 --- /dev/null +++ b/manager/app/src/main/AndroidManifest.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/manager/app/src/main/aidl/com/sukisu/zako/IKsuInterface.aidl b/manager/app/src/main/aidl/com/sukisu/zako/IKsuInterface.aidl new file mode 100644 index 0000000..50807e8 --- /dev/null +++ b/manager/app/src/main/aidl/com/sukisu/zako/IKsuInterface.aidl @@ -0,0 +1,10 @@ +// IKsuInterface.aidl +package com.sukisu.zako; + +import android.content.pm.PackageInfo; +import java.util.List; + +interface IKsuInterface { + int getPackageCount(); + List getPackages(int start, int maxCount); +} \ No newline at end of file diff --git a/manager/app/src/main/assets/5_10-mkbootfs b/manager/app/src/main/assets/5_10-mkbootfs new file mode 100644 index 0000000..2af1167 Binary files /dev/null and b/manager/app/src/main/assets/5_10-mkbootfs differ diff --git a/manager/app/src/main/assets/5_15+-mkbootfs b/manager/app/src/main/assets/5_15+-mkbootfs new file mode 100644 index 0000000..2eca159 Binary files /dev/null and b/manager/app/src/main/assets/5_15+-mkbootfs differ diff --git a/manager/app/src/main/assets/kpimg b/manager/app/src/main/assets/kpimg new file mode 100644 index 0000000..e64eb85 Binary files /dev/null and b/manager/app/src/main/assets/kpimg differ diff --git a/manager/app/src/main/assets/kptools b/manager/app/src/main/assets/kptools new file mode 100644 index 0000000..f1a2a57 Binary files /dev/null and b/manager/app/src/main/assets/kptools differ diff --git a/manager/app/src/main/assets/ksu_susfs_2.0.0 b/manager/app/src/main/assets/ksu_susfs_2.0.0 new file mode 100644 index 0000000..dbb0b6b Binary files /dev/null and b/manager/app/src/main/assets/ksu_susfs_2.0.0 differ diff --git a/manager/app/src/main/cpp/CMakeLists.txt b/manager/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..7fc4fdc --- /dev/null +++ b/manager/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,28 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html + +# Sets the minimum version of CMake required to build the native library. +cmake_minimum_required(VERSION 3.18.1) + +project("kernelsu") + +add_library(kernelsu + SHARED + jni.c + ksu.c + legacy.c +) + +find_library(log-lib log) + +if(ANDROID_ABI STREQUAL "arm64-v8a") + set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libzakosign.so) +elseif(ANDROID_ABI STREQUAL "armeabi-v7a") + set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/armeabi-v7a/libzakosign.so) +endif() + +if(ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "armeabi-v7a") + target_link_libraries(kernelsu ${log-lib} ${zakosign-lib}) +else() + target_link_libraries(kernelsu ${log-lib}) +endif() diff --git a/manager/app/src/main/cpp/jni.c b/manager/app/src/main/cpp/jni.c new file mode 100644 index 0000000..95818d6 --- /dev/null +++ b/manager/app/src/main/cpp/jni.c @@ -0,0 +1,452 @@ +#include "prelude.h" +#include "ksu.h" + +#include +#include +#include +#include +#include +#include + +NativeBridgeNP(getVersion, jint) { + uint32_t version = get_version(); + if (version > 0) { + return (jint)version; + } + // try legacy method as fallback + return legacy_get_info().version; +} + +// get VERSION FULL +NativeBridgeNP(getFullVersion, jstring) { + char buff[255] = { 0 }; + get_full_version((char *) &buff); + return GetEnvironment()->NewStringUTF(env, buff); +} + +NativeBridgeNP(getAllowList, jintArray) { + struct ksu_get_allow_list_cmd cmd = {}; + bool result = get_allow_list(&cmd); + + if (result) { + jsize array_size = (jsize)cmd.count; + if (array_size < 0 || (unsigned int)array_size != cmd.count) { + LogDebug("Invalid array size: %u", cmd.count); + return GetEnvironment()->NewIntArray(env, 0); + } + + jintArray array = GetEnvironment()->NewIntArray(env, array_size); + GetEnvironment()->SetIntArrayRegion(env, array, 0, array_size, (const jint *)(cmd.uids)); + + return array; + } + + return GetEnvironment()->NewIntArray(env, 0); +} + +NativeBridgeNP(isSafeMode, jboolean) { + return is_safe_mode(); +} + +NativeBridgeNP(isLkmMode, jboolean) { + return is_lkm_mode(); +} + +NativeBridgeNP(isManager, jboolean) { + return is_manager(); +} + +static void fillIntArray(JNIEnv *env, jobject list, int *data, int count) { + jclass cls = GetEnvironment()->GetObjectClass(env, list); + jmethodID add = GetEnvironment()->GetMethodID(env, cls, "add", "(Ljava/lang/Object;)Z"); + jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer"); + jmethodID constructor = GetEnvironment()->GetMethodID(env, integerCls, "", "(I)V"); + for (int i = 0; i < count; ++i) { + jobject integer = GetEnvironment()->NewObject(env, integerCls, constructor, data[i]); + GetEnvironment()->CallBooleanMethod(env, list, add, integer); + } +} + +static void addIntToList(JNIEnv *env, jobject list, int ele) { + jclass cls = GetEnvironment()->GetObjectClass(env, list); + jmethodID add = GetEnvironment()->GetMethodID(env, cls, "add", "(Ljava/lang/Object;)Z"); + jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer"); + jmethodID constructor = GetEnvironment()->GetMethodID(env, integerCls, "", "(I)V"); + jobject integer = GetEnvironment()->NewObject(env, integerCls, constructor, ele); + GetEnvironment()->CallBooleanMethod(env, list, add, integer); +} + +static uint64_t capListToBits(JNIEnv *env, jobject list) { + jclass cls = GetEnvironment()->GetObjectClass(env, list); + jmethodID get = GetEnvironment()->GetMethodID(env, cls, "get", "(I)Ljava/lang/Object;"); + jmethodID size = GetEnvironment()->GetMethodID(env, cls, "size", "()I"); + jint listSize = GetEnvironment()->CallIntMethod(env, list, size); + jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer"); + jmethodID intValue = GetEnvironment()->GetMethodID(env, integerCls, "intValue", "()I"); + uint64_t result = 0; + for (int i = 0; i < listSize; ++i) { + jobject integer = GetEnvironment()->CallObjectMethod(env, list, get, i); + int data = GetEnvironment()->CallIntMethod(env, integer, intValue); + + if (cap_valid(data)) { + result |= (1ULL << data); + } + } + + return result; +} + +static int getListSize(JNIEnv *env, jobject list) { + jclass cls = GetEnvironment()->GetObjectClass(env, list); + jmethodID size = GetEnvironment()->GetMethodID(env, cls, "size", "()I"); + return GetEnvironment()->CallIntMethod(env, list, size); +} + +static void fillArrayWithList(JNIEnv *env, jobject list, int *data, int count) { + jclass cls = GetEnvironment()->GetObjectClass(env, list); + jmethodID get = GetEnvironment()->GetMethodID(env, cls, "get", "(I)Ljava/lang/Object;"); + jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer"); + jmethodID intValue = GetEnvironment()->GetMethodID(env, integerCls, "intValue", "()I"); + for (int i = 0; i < count; ++i) { + jobject integer = GetEnvironment()->CallObjectMethod(env, list, get, i); + data[i] = GetEnvironment()->CallIntMethod(env, integer, intValue); + } +} + +NativeBridge(getAppProfile, jobject, jstring pkg, jint uid) { + if (GetEnvironment()->GetStringLength(env, pkg) > KSU_MAX_PACKAGE_NAME) { + return NULL; + } + + char key[KSU_MAX_PACKAGE_NAME] = { 0 }; + const char* cpkg = GetEnvironment()->GetStringUTFChars(env, pkg, nullptr); + strcpy(key, cpkg); + GetEnvironment()->ReleaseStringUTFChars(env, pkg, cpkg); + + struct app_profile profile = { 0 }; + profile.version = KSU_APP_PROFILE_VER; + + strcpy(profile.key, key); + profile.current_uid = uid; + + bool useDefaultProfile = get_app_profile(&profile) != 0; + + jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$Profile"); + jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "", "()V"); + jobject obj = GetEnvironment()->NewObject(env, cls, constructor); + jfieldID keyField = GetEnvironment()->GetFieldID(env, cls, "name", "Ljava/lang/String;"); + jfieldID currentUidField = GetEnvironment()->GetFieldID(env, cls, "currentUid", "I"); + jfieldID allowSuField = GetEnvironment()->GetFieldID(env, cls, "allowSu", "Z"); + + jfieldID rootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "rootUseDefault", "Z"); + jfieldID rootTemplateField = GetEnvironment()->GetFieldID(env, cls, "rootTemplate", "Ljava/lang/String;"); + + jfieldID uidField = GetEnvironment()->GetFieldID(env, cls, "uid", "I"); + jfieldID gidField = GetEnvironment()->GetFieldID(env, cls, "gid", "I"); + jfieldID groupsField = GetEnvironment()->GetFieldID(env, cls, "groups", "Ljava/util/List;"); + jfieldID capabilitiesField = GetEnvironment()->GetFieldID(env, cls, "capabilities", "Ljava/util/List;"); + jfieldID domainField = GetEnvironment()->GetFieldID(env, cls, "context", "Ljava/lang/String;"); + jfieldID namespacesField = GetEnvironment()->GetFieldID(env, cls, "namespace", "I"); + + jfieldID nonRootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "nonRootUseDefault", "Z"); + jfieldID umountModulesField = GetEnvironment()->GetFieldID(env, cls, "umountModules", "Z"); + + GetEnvironment()->SetObjectField(env, obj, keyField, GetEnvironment()->NewStringUTF(env, profile.key)); + GetEnvironment()->SetIntField(env, obj, currentUidField, profile.current_uid); + + if (useDefaultProfile) { + // no profile found, so just use default profile: + // don't allow root and use default profile! + LogDebug("use default profile for: %s, %d", key, uid); + + // allow_su = false + // non root use default = true + GetEnvironment()->SetBooleanField(env, obj, allowSuField, false); + GetEnvironment()->SetBooleanField(env, obj, nonRootUseDefaultField, true); + + return obj; + } + + bool allowSu = profile.allow_su; + + if (allowSu) { + GetEnvironment()->SetBooleanField(env, obj, rootUseDefaultField, (jboolean) profile.rp_config.use_default); + if (strlen(profile.rp_config.template_name) > 0) { + GetEnvironment()->SetObjectField(env, obj, rootTemplateField, + GetEnvironment()->NewStringUTF(env, profile.rp_config.template_name)); + } + + GetEnvironment()->SetIntField(env, obj, uidField, profile.rp_config.profile.uid); + GetEnvironment()->SetIntField(env, obj, gidField, profile.rp_config.profile.gid); + + jobject groupList = GetEnvironment()->GetObjectField(env, obj, groupsField); + int groupCount = profile.rp_config.profile.groups_count; + if (groupCount > KSU_MAX_GROUPS) { + LogDebug("kernel group count too large: %d???", groupCount); + groupCount = KSU_MAX_GROUPS; + } + fillIntArray(env, groupList, profile.rp_config.profile.groups, groupCount); + + jobject capList = GetEnvironment()->GetObjectField(env, obj, capabilitiesField); + for (int i = 0; i <= CAP_LAST_CAP; i++) { + if (profile.rp_config.profile.capabilities.effective & (1ULL << i)) { + addIntToList(env, capList, i); + } + } + + GetEnvironment()->SetObjectField(env, obj, domainField, + GetEnvironment()->NewStringUTF(env, profile.rp_config.profile.selinux_domain)); + GetEnvironment()->SetIntField(env, obj, namespacesField, profile.rp_config.profile.namespaces); + GetEnvironment()->SetBooleanField(env, obj, allowSuField, profile.allow_su); + } else { + GetEnvironment()->SetBooleanField(env, obj, nonRootUseDefaultField, profile.nrp_config.use_default); + GetEnvironment()->SetBooleanField(env, obj, umountModulesField, profile.nrp_config.profile.umount_modules); + } + + return obj; +} + +NativeBridge(setAppProfile, jboolean, jobject profile) { + jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$Profile"); + + jfieldID keyField = GetEnvironment()->GetFieldID(env, cls, "name", "Ljava/lang/String;"); + jfieldID currentUidField = GetEnvironment()->GetFieldID(env, cls, "currentUid", "I"); + jfieldID allowSuField = GetEnvironment()->GetFieldID(env, cls, "allowSu", "Z"); + + jfieldID rootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "rootUseDefault", "Z"); + jfieldID rootTemplateField = GetEnvironment()->GetFieldID(env, cls, "rootTemplate", "Ljava/lang/String;"); + + jfieldID uidField = GetEnvironment()->GetFieldID(env, cls, "uid", "I"); + jfieldID gidField = GetEnvironment()->GetFieldID(env, cls, "gid", "I"); + jfieldID groupsField = GetEnvironment()->GetFieldID(env, cls, "groups", "Ljava/util/List;"); + jfieldID capabilitiesField = GetEnvironment()->GetFieldID(env, cls, "capabilities", "Ljava/util/List;"); + jfieldID domainField = GetEnvironment()->GetFieldID(env, cls, "context", "Ljava/lang/String;"); + jfieldID namespacesField = GetEnvironment()->GetFieldID(env, cls, "namespace", "I"); + + jfieldID nonRootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "nonRootUseDefault", "Z"); + jfieldID umountModulesField = GetEnvironment()->GetFieldID(env, cls, "umountModules", "Z"); + + jobject key = GetEnvironment()->GetObjectField(env, profile, keyField); + if (!key) { + return false; + } + if (GetEnvironment()->GetStringLength(env, (jstring) key) > KSU_MAX_PACKAGE_NAME) { + return false; + } + + const char* cpkg = GetEnvironment()->GetStringUTFChars(env, (jstring) key, nullptr); + char p_key[KSU_MAX_PACKAGE_NAME] = { 0 }; + strcpy(p_key, cpkg); + GetEnvironment()->ReleaseStringUTFChars(env, (jstring) key, cpkg); + + jint currentUid = GetEnvironment()->GetIntField(env, profile, currentUidField); + + jint uid = GetEnvironment()->GetIntField(env, profile, uidField); + jint gid = GetEnvironment()->GetIntField(env, profile, gidField); + jobject groups = GetEnvironment()->GetObjectField(env, profile, groupsField); + jobject capabilities = GetEnvironment()->GetObjectField(env, profile, capabilitiesField); + jobject domain = GetEnvironment()->GetObjectField(env, profile, domainField); + jboolean allowSu = GetEnvironment()->GetBooleanField(env, profile, allowSuField); + jboolean umountModules = GetEnvironment()->GetBooleanField(env, profile, umountModulesField); + + struct app_profile p = { 0 }; + p.version = KSU_APP_PROFILE_VER; + + strcpy(p.key, p_key); + p.allow_su = allowSu; + p.current_uid = currentUid; + + if (allowSu) { + p.rp_config.use_default = GetEnvironment()->GetBooleanField(env, profile, rootUseDefaultField); + jobject templateName = GetEnvironment()->GetObjectField(env, profile, rootTemplateField); + if (templateName) { + const char* ctemplateName = GetEnvironment()->GetStringUTFChars(env, (jstring) templateName, nullptr); + strcpy(p.rp_config.template_name, ctemplateName); + GetEnvironment()->ReleaseStringUTFChars(env, (jstring) templateName, ctemplateName); + } + + p.rp_config.profile.uid = uid; + p.rp_config.profile.gid = gid; + + int groups_count = getListSize(env, groups); + if (groups_count > KSU_MAX_GROUPS) { + LogDebug("groups count too large: %d", groups_count); + return false; + } + p.rp_config.profile.groups_count = groups_count; + fillArrayWithList(env, groups, p.rp_config.profile.groups, groups_count); + + p.rp_config.profile.capabilities.effective = capListToBits(env, capabilities); + + const char* cdomain = GetEnvironment()->GetStringUTFChars(env, (jstring) domain, nullptr); + strcpy(p.rp_config.profile.selinux_domain, cdomain); + GetEnvironment()->ReleaseStringUTFChars(env, (jstring) domain, cdomain); + + p.rp_config.profile.namespaces = GetEnvironment()->GetIntField(env, profile, namespacesField); + } else { + p.nrp_config.use_default = GetEnvironment()->GetBooleanField(env, profile, nonRootUseDefaultField); + p.nrp_config.profile.umount_modules = umountModules; + } + + return set_app_profile(&p); +} + +NativeBridge(uidShouldUmount, jboolean, jint uid) { + return uid_should_umount(uid); +} + +NativeBridgeNP(isSuEnabled, jboolean) { + return is_su_enabled(); +} + +NativeBridge(setSuEnabled, jboolean, jboolean enabled) { + return set_su_enabled(enabled); +} + +NativeBridgeNP(isKernelUmountEnabled, jboolean) { + return is_kernel_umount_enabled(); +} + +NativeBridge(setKernelUmountEnabled, jboolean, jboolean enabled) { + return set_kernel_umount_enabled(enabled); +} + +NativeBridgeNP(isEnhancedSecurityEnabled, jboolean) { + return is_enhanced_security_enabled(); +} + +NativeBridge(setEnhancedSecurityEnabled, jboolean, jboolean enabled) { + return set_enhanced_security_enabled(enabled); +} + +NativeBridgeNP(isSuLogEnabled, jboolean) { + return is_sulog_enabled(); +} + +NativeBridge(setSuLogEnabled, jboolean, jboolean enabled) { + return set_sulog_enabled(enabled); +} + +NativeBridge(getUserName, jstring, jint uid) { + struct passwd *pw = getpwuid((uid_t) uid); + if (pw && pw->pw_name && pw->pw_name[0] != '\0') { + return GetEnvironment()->NewStringUTF(env, pw->pw_name); + } + return NULL; +} + +// Check if KPM is enabled +NativeBridgeNP(isKPMEnabled, jboolean) { + return is_KPM_enable(); +} + +// Get HOOK type +NativeBridgeNP(getHookType, jstring) { + char hook_type[32] = { 0 }; + get_hook_type((char *) &hook_type); + return GetEnvironment()->NewStringUTF(env, hook_type); +} + +// dynamic manager +NativeBridge(setDynamicManager, jboolean, jint size, jstring hash) { + if (!hash) { + LogDebug("setDynamicManager: hash is null"); + return false; + } + + const char* chash = GetEnvironment()->GetStringUTFChars(env, hash, nullptr); + bool result = set_dynamic_manager((unsigned int)size, chash); + GetEnvironment()->ReleaseStringUTFChars(env, hash, chash); + + LogDebug("setDynamicManager: size=0x%x, result=%d", size, result); + return result; +} + +NativeBridgeNP(getDynamicManager, jobject) { + struct dynamic_manager_user_config config; + bool result = get_dynamic_manager(&config); + + if (!result) { + LogDebug("getDynamicManager: failed to get dynamic manager config"); + return NULL; + } + + jobject obj = CREATE_JAVA_OBJECT("com/sukisu/ultra/Natives$DynamicManagerConfig"); + jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$DynamicManagerConfig"); + + SET_INT_FIELD(obj, cls, size, (jint)config.size); + SET_STRING_FIELD(obj, cls, hash, config.hash); + + LogDebug("getDynamicManager: size=0x%x, hash=%.16s...", config.size, config.hash); + return obj; +} + +NativeBridgeNP(clearDynamicManager, jboolean) { + bool result = clear_dynamic_manager(); + LogDebug("clearDynamicManager: result=%d", result); + return result; +} + +// Get a list of active managers +NativeBridgeNP(getManagersList, jobject) { + struct manager_list_info managerListInfo; + bool result = get_managers_list(&managerListInfo); + + if (!result) { + LogDebug("getManagersList: failed to get active managers list"); + return NULL; + } + + jobject obj = CREATE_JAVA_OBJECT("com/sukisu/ultra/Natives$ManagersList"); + jclass managerListCls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$ManagersList"); + + SET_INT_FIELD(obj, managerListCls, count, (jint)managerListInfo.count); + + jobject managersList = CREATE_ARRAYLIST(); + + for (int i = 0; i < managerListInfo.count; i++) { + jobject managerInfo = CREATE_JAVA_OBJECT_WITH_PARAMS( + "com/sukisu/ultra/Natives$ManagerInfo", + "(II)V", + (jint)managerListInfo.managers[i].uid, + (jint)managerListInfo.managers[i].signature_index + ); + ADD_TO_LIST(managersList, managerInfo); + } + + SET_OBJECT_FIELD(obj, managerListCls, managers, managersList); + + LogDebug("getManagersList: count=%d", managerListInfo.count); + return obj; +} + +NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) { +#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) + if (!modulePath) { + LogDebug("verifyModuleSignature: modulePath is null"); + return false; + } + + const char* cModulePath = GetEnvironment()->GetStringUTFChars(env, modulePath, nullptr); + bool result = verify_module_signature(cModulePath); + GetEnvironment()->ReleaseStringUTFChars(env, modulePath, cModulePath); + + LogDebug("verifyModuleSignature: path=%s, result=%d", cModulePath, result); + return result; +#else + LogDebug("verifyModuleSignature: not supported on non-ARM architecture"); + return false; +#endif +} + +NativeBridgeNP(isUidScannerEnabled, jboolean) { + return is_uid_scanner_enabled(); +} + +NativeBridge(setUidScannerEnabled, jboolean, jboolean enabled) { + return set_uid_scanner_enabled(enabled); +} + +NativeBridgeNP(clearUidScannerEnvironment, jboolean) { + return clear_uid_scanner_environment(); +} \ No newline at end of file diff --git a/manager/app/src/main/cpp/ksu.c b/manager/app/src/main/cpp/ksu.c new file mode 100644 index 0000000..0fc8866 --- /dev/null +++ b/manager/app/src/main/cpp/ksu.c @@ -0,0 +1,406 @@ +// +// Created by weishu on 2022/12/9. +// + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "prelude.h" +#include "ksu.h" + +#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) + +// Zako extern declarations +#define ZAKO_ESV_IMPORTANT_ERROR 1 << 31 +extern int zako_sys_file_open(const char* path); +extern uint32_t zako_file_verify_esig(int fd, uint32_t flags); +extern const char* zako_file_verrcidx2str(uint8_t index); + +#endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM + +static int fd = -1; + +static inline int scan_driver_fd() { + const char *kName = "[ksu_driver]"; + DIR *fd_dir = opendir("/proc/self/fd"); + if (!fd_dir) { + return -1; + } + + int found = -1; + struct dirent *de; + char path[64]; + char target[PATH_MAX]; + + while ((de = readdir(fd_dir)) != NULL) { + if (de->d_name[0] == '.') { + continue; + } + + char *endptr = nullptr; + long fd_long = strtol(de->d_name, &endptr, 10); + if (!de->d_name[0] || *endptr != '\0' || fd_long < 0 || fd_long > INT_MAX) { + continue; + } + + snprintf(path, sizeof(path), "/proc/self/fd/%s", de->d_name); + ssize_t n = readlink(path, target, sizeof(target) - 1); + if (n < 0) { + continue; + } + target[n] = '\0'; + + const char *base = strrchr(target, '/'); + base = base ? base + 1 : target; + + if (strstr(base, kName)) { + found = (int)fd_long; + break; + } + } + + closedir(fd_dir); + return found; +} + +static int ksuctl(unsigned long op, void* arg) { + if (fd < 0) { + fd = scan_driver_fd(); + } + return ioctl(fd, op, arg); +} + +static struct ksu_get_info_cmd g_version = {0}; + +struct ksu_get_info_cmd get_info() { + if (!g_version.version) { + ksuctl(KSU_IOCTL_GET_INFO, &g_version); + } + return g_version; +} + +uint32_t get_version() { + auto info = get_info(); + return info.version; +} + +bool get_allow_list(struct ksu_get_allow_list_cmd *cmd) { + if (ksuctl(KSU_IOCTL_GET_ALLOW_LIST, cmd) == 0) { + return true; + } + + // fallback to legacy + int size = 0; + int uids[1024]; + if (legacy_get_allow_list(uids, &size)) { + cmd->count = size; + memcpy(cmd->uids, uids, sizeof(int) * size); + return true; + } + + return false; +} + +bool is_safe_mode() { + struct ksu_check_safemode_cmd cmd = {}; + if (ksuctl(KSU_IOCTL_CHECK_SAFEMODE, &cmd) == 0) { + return cmd.in_safe_mode; + } + // fallback + return legacy_is_safe_mode(); +} + +bool is_lkm_mode() { + auto info = get_info(); + if (info.version > 0) { + return (info.flags & 0x1) != 0; + } + // Legacy Compatible + return (legacy_get_info().flags & 0x1) != 0; +} + +bool is_manager() { + auto info = get_info(); + if (info.version > 0) { + return (info.flags & 0x2) != 0; + } + // Legacy Compatible + return legacy_get_info().version > 0; +} + +bool uid_should_umount(int uid) { + struct ksu_uid_should_umount_cmd cmd = {}; + cmd.uid = uid; + if (ksuctl(KSU_IOCTL_UID_SHOULD_UMOUNT, &cmd) == 0) { + return cmd.should_umount; + } + return legacy_uid_should_umount(uid); +} + +bool set_app_profile(const struct app_profile *profile) { + struct ksu_set_app_profile_cmd cmd = {}; + cmd.profile = *profile; + if (ksuctl(KSU_IOCTL_SET_APP_PROFILE, &cmd) == 0) { + return true; + } + return legacy_set_app_profile(profile); +} + +int get_app_profile(struct app_profile *profile) { + struct ksu_get_app_profile_cmd cmd = {.profile = *profile}; + int ret = ksuctl(KSU_IOCTL_GET_APP_PROFILE, &cmd); + if (ret == 0) { + *profile = cmd.profile; + return 0; + } + return legacy_get_app_profile(profile->key, profile) ? 0 : -1; +} + +bool set_su_enabled(bool enabled) { + struct ksu_set_feature_cmd cmd = {}; + cmd.feature_id = KSU_FEATURE_SU_COMPAT; + cmd.value = enabled ? 1 : 0; + if (ksuctl(KSU_IOCTL_SET_FEATURE, &cmd) == 0) { + return true; + } + return legacy_set_su_enabled(enabled); +} + +bool is_su_enabled() { + struct ksu_get_feature_cmd cmd = {}; + cmd.feature_id = KSU_FEATURE_SU_COMPAT; + if (ksuctl(KSU_IOCTL_GET_FEATURE, &cmd) == 0 && cmd.supported) { + return cmd.value != 0; + } + return legacy_is_su_enabled(); +} + +static inline bool get_feature(uint32_t feature_id, uint64_t *out_value, bool *out_supported) { + struct ksu_get_feature_cmd cmd = {}; + cmd.feature_id = feature_id; + if (ksuctl(KSU_IOCTL_GET_FEATURE, &cmd) != 0) { + return false; + } + if (out_value) *out_value = cmd.value; + if (out_supported) *out_supported = cmd.supported; + return true; +} + +static inline bool set_feature(uint32_t feature_id, uint64_t value) { + struct ksu_set_feature_cmd cmd = {}; + cmd.feature_id = feature_id; + cmd.value = value; + return ksuctl(KSU_IOCTL_SET_FEATURE, &cmd) == 0; +} + +bool set_kernel_umount_enabled(bool enabled) { + return set_feature(KSU_FEATURE_KERNEL_UMOUNT, enabled ? 1 : 0); +} + +bool is_kernel_umount_enabled() { + uint64_t value = 0; + bool supported = false; + if (!get_feature(KSU_FEATURE_KERNEL_UMOUNT, &value, &supported)) { + return false; + } + if (!supported) { + return false; + } + return value != 0; +} + +bool set_enhanced_security_enabled(bool enabled) { + return set_feature(KSU_FEATURE_ENHANCED_SECURITY, enabled ? 1 : 0); +} + +bool is_enhanced_security_enabled() { + uint64_t value = 0; + bool supported = false; + if (!get_feature(KSU_FEATURE_ENHANCED_SECURITY, &value, &supported)) { + return false; + } + if (!supported) { + return false; + } + return value != 0; +} + +bool set_sulog_enabled(bool enabled) { + return set_feature(KSU_FEATURE_SULOG, enabled ? 1 : 0); +} + +bool is_sulog_enabled() { + uint64_t value = 0; + bool supported = false; + if (!get_feature(KSU_FEATURE_SULOG, &value, &supported)) { + return false; + } + if (!supported) { + return false; + } + return value != 0; +} + +void get_full_version(char* buff) { + struct ksu_get_full_version_cmd cmd = {0}; + if (ksuctl(KSU_IOCTL_GET_FULL_VERSION, &cmd) == 0) { + strncpy(buff, cmd.version_full, KSU_FULL_VERSION_STRING - 1); + buff[KSU_FULL_VERSION_STRING - 1] = '\0'; + } else { + return legacy_get_full_version(buff); + } +} + +bool is_KPM_enable(void) { + struct ksu_enable_kpm_cmd cmd = {}; + if (ksuctl(KSU_IOCTL_ENABLE_KPM, &cmd) == 0 && cmd.enabled) { + return true; + } + return legacy_is_KPM_enable(); +} + +void get_hook_type(char *buff) { + struct ksu_hook_type_cmd cmd = {0}; + if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) { + strncpy(buff, cmd.hook_type, 32 - 1); + buff[32 - 1] = '\0'; + } else { + legacy_get_hook_type(buff, 32); + } +} + +bool set_dynamic_manager(unsigned int size, const char *hash) +{ + struct ksu_dynamic_manager_cmd cmd = {0}; + cmd.config.operation = DYNAMIC_MANAGER_OP_SET; + cmd.config.size = size; + strlcpy(cmd.config.hash, hash, sizeof(cmd.config.hash)); + + return ksuctl(KSU_IOCTL_DYNAMIC_MANAGER, &cmd) == 0; +} + +bool get_dynamic_manager(struct dynamic_manager_user_config *cfg) +{ + if (!cfg) + return false; + + struct ksu_dynamic_manager_cmd cmd = {0}; + cmd.config.operation = DYNAMIC_MANAGER_OP_GET; + + if (ksuctl(KSU_IOCTL_DYNAMIC_MANAGER, &cmd) != 0) + return false; + + *cfg = cmd.config; + return true; +} + +bool clear_dynamic_manager(void) +{ + struct ksu_dynamic_manager_cmd cmd = {0}; + cmd.config.operation = DYNAMIC_MANAGER_OP_CLEAR; + return ksuctl(KSU_IOCTL_DYNAMIC_MANAGER, &cmd) == 0; +} + +bool get_managers_list(struct manager_list_info *info) +{ + if (!info) + return false; + struct ksu_get_managers_cmd cmd = {0}; + if (ksuctl(KSU_IOCTL_GET_MANAGERS, &cmd) != 0) + return false; + + *info = cmd.manager_info; + return true; +} + +bool is_uid_scanner_enabled(void) +{ + bool status = false; + + struct ksu_enable_uid_scanner_cmd cmd = { + .operation = UID_SCANNER_OP_GET_STATUS, + .status_ptr = (__u64)(uintptr_t)&status + }; + + return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd) == 0 != 0 && status; +} + +bool set_uid_scanner_enabled(bool enabled) +{ + struct ksu_enable_uid_scanner_cmd cmd = { + .operation = UID_SCANNER_OP_TOGGLE, + .enabled = enabled + }; + return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd); +} + +bool clear_uid_scanner_environment(void) +{ + struct ksu_enable_uid_scanner_cmd cmd = { + .operation = UID_SCANNER_OP_CLEAR_ENV + }; + return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd); +} + +bool verify_module_signature(const char* input) { +#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) + if (input == NULL) { + LogDebug("verify_module_signature: input path is null"); + return false; + } + + int file_fd = zako_sys_file_open(input); + if (file_fd < 0) { + LogDebug("verify_module_signature: failed to open file: %s", input); + return false; + } + + uint32_t results = zako_file_verify_esig(file_fd, 0); + + if (results != 0) { + /* If important error occured, verification process should + be considered as failed due to unexpected modification + potentially happened. */ + if ((results & ZAKO_ESV_IMPORTANT_ERROR) != 0) { + LogDebug("verify_module_signature: Verification failed! (important error)"); + } else { + /* This is for manager that doesn't want to do certificate checks */ + LogDebug("verify_module_signature: Verification partially passed"); + } + } else { + LogDebug("verify_module_signature: Verification passed!"); + goto exit; + } + + /* Go through all bit fields */ + for (size_t i = 0; i < sizeof(uint32_t) * 8; i++) { + if ((results & (1 << i)) == 0) { + continue; + } + + /* Convert error bit field index into human readable string */ + const char* message = zako_file_verrcidx2str((uint8_t)i); + // Error message: message + if (message != NULL) { + LogDebug("verify_module_signature: Error bit %zu: %s", i, message); + } else { + LogDebug("verify_module_signature: Error bit %zu: Unknown error", i); + } + } + + exit: + close(file_fd); + LogDebug("verify_module_signature: path=%s, results=0x%x, success=%s", + input, results, (results == 0) ? "true" : "false"); + return results == 0; +#else + LogDebug("verify_module_signature: not supported on non-ARM architecture, path=%s", input ? input : "null"); + return false; +#endif +} diff --git a/manager/app/src/main/cpp/ksu.h b/manager/app/src/main/cpp/ksu.h new file mode 100644 index 0000000..efcaa05 --- /dev/null +++ b/manager/app/src/main/cpp/ksu.h @@ -0,0 +1,300 @@ +// +// Created by weishu on 2022/12/9. +// + +#ifndef KERNELSU_KSU_H +#define KERNELSU_KSU_H + +#include "prelude.h" +#include +#include +#include +#include +#include + +#define KSU_FULL_VERSION_STRING 255 + +uint32_t get_version(); + +bool uid_should_umount(int uid); + +bool is_safe_mode(); + +bool is_lkm_mode(); + +bool is_manager(); + +void get_full_version(char* buff); + +#define KSU_APP_PROFILE_VER 2 +#define KSU_MAX_PACKAGE_NAME 256 +// NGROUPS_MAX for Linux is 65535 generally, but we only supports 32 groups. +#define KSU_MAX_GROUPS 32 +#define KSU_SELINUX_DOMAIN 64 + +#define DYNAMIC_MANAGER_OP_SET 0 +#define DYNAMIC_MANAGER_OP_GET 1 +#define DYNAMIC_MANAGER_OP_CLEAR 2 + +#define UID_SCANNER_OP_GET_STATUS 0 +#define UID_SCANNER_OP_TOGGLE 1 +#define UID_SCANNER_OP_CLEAR_ENV 2 + +struct dynamic_manager_user_config { + unsigned int operation; + unsigned int size; + char hash[65]; +}; + + +struct root_profile { + int32_t uid; + int32_t gid; + + int32_t groups_count; + int32_t groups[KSU_MAX_GROUPS]; + + // kernel_cap_t is u32[2] for capabilities v3 + struct { + uint64_t effective; + uint64_t permitted; + uint64_t inheritable; + } capabilities; + + char selinux_domain[KSU_SELINUX_DOMAIN]; + + int32_t namespaces; +}; + +struct non_root_profile { + bool umount_modules; +}; + +struct app_profile { + // It may be utilized for backward compatibility, although we have never explicitly made any promises regarding this. + uint32_t version; + + // this is usually the package of the app, but can be other value for special apps + char key[KSU_MAX_PACKAGE_NAME]; + int32_t current_uid; + bool allow_su; + + union { + struct { + bool use_default; + char template_name[KSU_MAX_PACKAGE_NAME]; + + struct root_profile profile; + } rp_config; + + struct { + bool use_default; + + struct non_root_profile profile; + } nrp_config; + }; +}; + +struct manager_list_info { + int count; + struct { + uid_t uid; + int signature_index; + } managers[2]; +}; + +bool set_app_profile(const struct app_profile* profile); + +int get_app_profile(struct app_profile* profile); + +bool is_KPM_enable(); + +void get_hook_type(char* hook_type); + +bool set_dynamic_manager(unsigned int size, const char* hash); + +bool get_dynamic_manager(struct dynamic_manager_user_config* config); + +bool clear_dynamic_manager(); + +bool get_managers_list(struct manager_list_info* info); + +bool verify_module_signature(const char* input); + +bool is_uid_scanner_enabled(); + +bool set_uid_scanner_enabled(bool enabled); + +bool clear_uid_scanner_environment(); + +// Feature IDs +enum ksu_feature_id { + KSU_FEATURE_SU_COMPAT = 0, + KSU_FEATURE_KERNEL_UMOUNT = 1, + KSU_FEATURE_ENHANCED_SECURITY = 2, + KSU_FEATURE_SULOG = 3, +}; + +// Generic feature API +struct ksu_get_feature_cmd { + uint32_t feature_id; // Input: feature ID + uint64_t value; // Output: feature value/state + uint8_t supported; // Output: whether the feature is supported +}; + +struct ksu_set_feature_cmd { + uint32_t feature_id; // Input: feature ID + uint64_t value; // Input: feature value/state to set +}; + +struct ksu_become_daemon_cmd { + uint8_t token[65]; // Input: daemon token (null-terminated) +}; + +struct ksu_get_info_cmd { + uint32_t version; // Output: KERNEL_SU_VERSION + uint32_t flags; // Output: flags (bit 0: MODULE mode) + uint32_t features; // Output: max feature ID supported (KSU_FEATURE_MAX) +}; + +struct ksu_report_event_cmd { + uint32_t event; // Input: EVENT_POST_FS_DATA, EVENT_BOOT_COMPLETED, etc. +}; + +struct ksu_set_sepolicy_cmd { + uint64_t cmd; // Input: sepolicy command + uint64_t arg; // Input: sepolicy argument pointer +}; + +struct ksu_check_safemode_cmd { + uint8_t in_safe_mode; // Output: true if in safe mode, false otherwise +}; + +struct ksu_get_allow_list_cmd { + uint32_t uids[128]; // Output: array of allowed/denied UIDs + uint32_t count; // Output: number of UIDs in array + uint8_t allow; // Input: true for allow list, false for deny list +}; + +struct ksu_uid_granted_root_cmd { + uint32_t uid; // Input: target UID to check + uint8_t granted; // Output: true if granted, false otherwise +}; + +struct ksu_uid_should_umount_cmd { + uint32_t uid; // Input: target UID to check + uint8_t should_umount; // Output: true if should umount, false otherwise +}; + +struct ksu_get_manager_uid_cmd { + uint32_t uid; // Output: manager UID +}; + +struct ksu_set_manager_uid_cmd { + uint32_t uid; // Input: new manager UID +}; + +struct ksu_get_app_profile_cmd { + struct app_profile profile; // Input/Output: app profile structure +}; + +struct ksu_set_app_profile_cmd { + struct app_profile profile; // Input: app profile structure +}; + +// Su compat +bool set_su_enabled(bool enabled); +bool is_su_enabled(); + +// Kernel umount +bool set_kernel_umount_enabled(bool enabled); +bool is_kernel_umount_enabled(); + +// Enhanced security +bool set_enhanced_security_enabled(bool enabled); +bool is_enhanced_security_enabled(); + +// Su log +bool set_sulog_enabled(bool enabled); +bool is_sulog_enabled(); + +// Other command structures +struct ksu_get_full_version_cmd { + char version_full[KSU_FULL_VERSION_STRING]; // Output: full version string +}; + +struct ksu_hook_type_cmd { + char hook_type[32]; // Output: hook type string +}; + +struct ksu_enable_kpm_cmd { + uint8_t enabled; // Output: true if KPM is enabled +}; + +struct ksu_dynamic_manager_cmd { + struct dynamic_manager_user_config config; // Input/Output: dynamic manager config +}; + +struct ksu_get_managers_cmd { + struct manager_list_info manager_info; // Output: manager list information +}; + +struct ksu_enable_uid_scanner_cmd { + uint32_t operation; // Input: operation type (UID_SCANNER_OP_GET_STATUS, UID_SCANNER_OP_TOGGLE, UID_SCANNER_OP_CLEAR_ENV) + uint32_t enabled; // Input: enable or disable (for UID_SCANNER_OP_TOGGLE) + uint64_t status_ptr; // Input: pointer to store status (for UID_SCANNER_OP_GET_STATUS) +}; + +// IOCTL command definitions +#define KSU_IOCTL_GRANT_ROOT _IOC(_IOC_NONE, 'K', 1, 0) +#define KSU_IOCTL_GET_INFO _IOC(_IOC_READ, 'K', 2, 0) +#define KSU_IOCTL_REPORT_EVENT _IOC(_IOC_WRITE, 'K', 3, 0) +#define KSU_IOCTL_SET_SEPOLICY _IOC(_IOC_READ|_IOC_WRITE, 'K', 4, 0) +#define KSU_IOCTL_CHECK_SAFEMODE _IOC(_IOC_READ, 'K', 5, 0) +#define KSU_IOCTL_GET_ALLOW_LIST _IOC(_IOC_READ|_IOC_WRITE, 'K', 6, 0) +#define KSU_IOCTL_GET_DENY_LIST _IOC(_IOC_READ|_IOC_WRITE, 'K', 7, 0) +#define KSU_IOCTL_UID_GRANTED_ROOT _IOC(_IOC_READ|_IOC_WRITE, 'K', 8, 0) +#define KSU_IOCTL_UID_SHOULD_UMOUNT _IOC(_IOC_READ|_IOC_WRITE, 'K', 9, 0) +#define KSU_IOCTL_GET_MANAGER_UID _IOC(_IOC_READ, 'K', 10, 0) +#define KSU_IOCTL_GET_APP_PROFILE _IOC(_IOC_READ|_IOC_WRITE, 'K', 11, 0) +#define KSU_IOCTL_SET_APP_PROFILE _IOC(_IOC_WRITE, 'K', 12, 0) +#define KSU_IOCTL_GET_FEATURE _IOC(_IOC_READ|_IOC_WRITE, 'K', 13, 0) +#define KSU_IOCTL_SET_FEATURE _IOC(_IOC_WRITE, 'K', 14, 0) + +// Other IOCTL command definitions +#define KSU_IOCTL_GET_FULL_VERSION _IOC(_IOC_READ, 'K', 100, 0) +#define KSU_IOCTL_HOOK_TYPE _IOC(_IOC_READ, 'K', 101, 0) +#define KSU_IOCTL_ENABLE_KPM _IOC(_IOC_READ, 'K', 102, 0) +#define KSU_IOCTL_DYNAMIC_MANAGER _IOC(_IOC_READ|_IOC_WRITE, 'K', 103, 0) +#define KSU_IOCTL_GET_MANAGERS _IOC(_IOC_READ|_IOC_WRITE, 'K', 104, 0) +#define KSU_IOCTL_ENABLE_UID_SCANNER _IOC(_IOC_READ|_IOC_WRITE, 'K', 105, 0) + +bool get_allow_list(struct ksu_get_allow_list_cmd *); + +// Legacy Compatible +struct ksu_version_info legacy_get_info(); + +struct ksu_version_info { + int32_t version; + int32_t flags; +}; + +bool legacy_get_allow_list(int *uids, int *size); +bool legacy_is_safe_mode(); +bool legacy_uid_should_umount(int uid); +bool legacy_set_app_profile(const struct app_profile* profile); +bool legacy_get_app_profile(char* key, struct app_profile* profile); +bool legacy_set_su_enabled(bool enabled); +bool legacy_is_su_enabled(); +bool legacy_is_KPM_enable(); +bool legacy_get_hook_type(char* hook_type, size_t size); +void legacy_get_full_version(char* buff); +bool legacy_set_dynamic_manager(unsigned int size, const char* hash); +bool legacy_get_dynamic_manager(struct dynamic_manager_user_config* config); +bool legacy_clear_dynamic_manager(); +bool legacy_get_managers_list(struct manager_list_info* info); +bool legacy_is_uid_scanner_enabled(); +bool legacy_set_uid_scanner_enabled(bool enabled); +bool legacy_clear_uid_scanner_environment(); + +#endif //KERNELSU_KSU_H \ No newline at end of file diff --git a/manager/app/src/main/cpp/legacy.c b/manager/app/src/main/cpp/legacy.c new file mode 100644 index 0000000..de72a32 --- /dev/null +++ b/manager/app/src/main/cpp/legacy.c @@ -0,0 +1,163 @@ +// +// Created by shirkneko on 2025/11/3. +// +// Legacy Compatible +#include +#include +#include +#include +#include +#include +#include +#include + +#include "prelude.h" +#include "ksu.h" + +#define KERNEL_SU_OPTION 0xDEADBEEF + +#define CMD_GRANT_ROOT 0 + +#define CMD_BECOME_MANAGER 1 +#define CMD_GET_VERSION 2 +#define CMD_ALLOW_SU 3 +#define CMD_DENY_SU 4 +#define CMD_GET_SU_LIST 5 +#define CMD_GET_DENY_LIST 6 +#define CMD_CHECK_SAFEMODE 9 + +#define CMD_GET_APP_PROFILE 10 +#define CMD_SET_APP_PROFILE 11 + +#define CMD_IS_UID_GRANTED_ROOT 12 +#define CMD_IS_UID_SHOULD_UMOUNT 13 +#define CMD_IS_SU_ENABLED 14 +#define CMD_ENABLE_SU 15 + +#define CMD_GET_VERSION_FULL 0xC0FFEE1A + +#define CMD_ENABLE_KPM 100 +#define CMD_HOOK_TYPE 101 +#define CMD_DYNAMIC_MANAGER 103 +#define CMD_GET_MANAGERS 104 +#define CMD_ENABLE_UID_SCANNER 105 + +static bool ksuctl(int cmd, void* arg1, void* arg2) { + int32_t result = 0; + int32_t rtn = prctl(KERNEL_SU_OPTION, cmd, arg1, arg2, &result); + return result == KERNEL_SU_OPTION && rtn == -1; +} + +struct ksu_version_info legacy_get_info() +{ + int32_t version = -1; + int32_t flags = 0; + ksuctl(CMD_GET_VERSION, &version, &flags); + return (struct ksu_version_info){version, flags}; +} + +bool legacy_get_allow_list(int *uids, int *size) { + return ksuctl(CMD_GET_SU_LIST, uids, size); +} + +bool legacy_is_safe_mode() { + return ksuctl(CMD_CHECK_SAFEMODE, NULL, NULL); +} + +bool legacy_uid_should_umount(int uid) { + int should; + return ksuctl(CMD_IS_UID_SHOULD_UMOUNT, (void*) ((size_t) uid), &should) && should; +} + +bool legacy_set_app_profile(const struct app_profile* profile) { + return ksuctl(CMD_SET_APP_PROFILE, (void*) profile, NULL); +} + +bool legacy_get_app_profile(char* key, struct app_profile* profile) { + return ksuctl(CMD_GET_APP_PROFILE, profile, NULL); +} + +bool legacy_set_su_enabled(bool enabled) { + return ksuctl(CMD_ENABLE_SU, (void*) enabled, NULL); +} + +bool legacy_is_su_enabled() { + int enabled = true; + // if ksuctl failed, we assume su is enabled, and it cannot be disabled. + ksuctl(CMD_IS_SU_ENABLED, &enabled, NULL); + return enabled; +} + +bool legacy_is_KPM_enable() { + int enabled = false; + ksuctl(CMD_ENABLE_KPM, &enabled, NULL); + return enabled; +} + +bool legacy_get_hook_type(char* hook_type, size_t size) { + if (hook_type == NULL || size == 0) { + return false; + } + + static char cached_hook_type[16] = {0}; + if (cached_hook_type[0] == '\0') { + if (!ksuctl(CMD_HOOK_TYPE, cached_hook_type, NULL)) { + strcpy(cached_hook_type, "Unknown"); + } + } + + strncpy(hook_type, cached_hook_type, size - 1); + hook_type[size - 1] = '\0'; + return true; +} + +void legacy_get_full_version(char* buff) { + ksuctl(CMD_GET_VERSION_FULL, buff, NULL); +} + +bool legacy_set_dynamic_manager(unsigned int size, const char* hash) { + if (hash == NULL) { + return false; + } + struct dynamic_manager_user_config config; + config.operation = DYNAMIC_MANAGER_OP_SET; + config.size = size; + strncpy(config.hash, hash, sizeof(config.hash) - 1); + config.hash[sizeof(config.hash) - 1] = '\0'; + return ksuctl(CMD_DYNAMIC_MANAGER, &config, NULL); +} + +bool legacy_get_dynamic_manager(struct dynamic_manager_user_config* config) { + if (config == NULL) { + return false; + } + config->operation = DYNAMIC_MANAGER_OP_GET; + return ksuctl(CMD_DYNAMIC_MANAGER, config, NULL); +} + +bool legacy_clear_dynamic_manager() { + struct dynamic_manager_user_config config; + config.operation = DYNAMIC_MANAGER_OP_CLEAR; + return ksuctl(CMD_DYNAMIC_MANAGER, &config, NULL); +} + +bool legacy_get_managers_list(struct manager_list_info* info) { + if (info == NULL) { + return false; + } + return ksuctl(CMD_GET_MANAGERS, info, NULL); +} + +bool legacy_is_uid_scanner_enabled() { + bool status = false; + ksuctl(CMD_ENABLE_UID_SCANNER, (void*)0, &status); + return status; +} + +bool legacy_set_uid_scanner_enabled(bool enabled) { + return ksuctl(CMD_ENABLE_UID_SCANNER, (void*)1, (void*)enabled); +} + +bool legacy_clear_uid_scanner_environment() { + return ksuctl(CMD_ENABLE_UID_SCANNER, (void*)2, NULL); +} \ No newline at end of file diff --git a/manager/app/src/main/cpp/prelude.h b/manager/app/src/main/cpp/prelude.h new file mode 100644 index 0000000..18e19fa --- /dev/null +++ b/manager/app/src/main/cpp/prelude.h @@ -0,0 +1,70 @@ + +#ifndef KERNELSU_PRELUDE_H +#define KERNELSU_PRELUDE_H + +#include +#include +#include +#include +#include + +#define GetEnvironment() (*env) +#define NativeBridge(fn, rtn, ...) JNIEXPORT rtn JNICALL Java_com_sukisu_ultra_Natives_##fn(JNIEnv* env, jclass clazz, __VA_ARGS__) +#define NativeBridgeNP(fn, rtn) JNIEXPORT rtn JNICALL Java_com_sukisu_ultra_Natives_##fn(JNIEnv* env, jclass clazz) + +// Macros to simplify field setup +#define SET_BOOLEAN_FIELD(obj, cls, fieldName, value) do { \ + jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "Z"); \ + GetEnvironment()->SetBooleanField(env, obj, field, value); \ +} while(0) + +#define SET_INT_FIELD(obj, cls, fieldName, value) do { \ + jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "I"); \ + GetEnvironment()->SetIntField(env, obj, field, value); \ +} while(0) + +#define SET_STRING_FIELD(obj, cls, fieldName, value) do { \ + jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "Ljava/lang/String;"); \ + GetEnvironment()->SetObjectField(env, obj, field, GetEnvironment()->NewStringUTF(env, value)); \ +} while(0) + +#define SET_OBJECT_FIELD(obj, cls, fieldName, value) do { \ + jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "Ljava/util/List;"); \ + GetEnvironment()->SetObjectField(env, obj, field, value); \ +} while(0) + +// Macros for creating Java objects +#define CREATE_JAVA_OBJECT(className) ({ \ + jclass cls = GetEnvironment()->FindClass(env, className); \ + jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "", "()V"); \ + GetEnvironment()->NewObject(env, cls, constructor); \ +}) + +// Macros for creating ArrayList +#define CREATE_ARRAYLIST() ({ \ + jclass arrayListCls = GetEnvironment()->FindClass(env, "java/util/ArrayList"); \ + jmethodID constructor = GetEnvironment()->GetMethodID(env, arrayListCls, "", "()V"); \ + GetEnvironment()->NewObject(env, arrayListCls, constructor); \ +}) + +// Macros for adding elements to an ArrayList +#define ADD_TO_LIST(list, item) do { \ + jclass cls = GetEnvironment()->GetObjectClass(env, list); \ + jmethodID addMethod = GetEnvironment()->GetMethodID(env, cls, "add", "(Ljava/lang/Object;)Z"); \ + GetEnvironment()->CallBooleanMethod(env, list, addMethod, item); \ +} while(0) + +// Macros for creating Java objects with parameter constructors +#define CREATE_JAVA_OBJECT_WITH_PARAMS(className, signature, ...) ({ \ + jclass cls = GetEnvironment()->FindClass(env, className); \ + jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "", signature); \ + GetEnvironment()->NewObject(env, cls, constructor, __VA_ARGS__); \ +}) + +#ifdef NDEBUG +#define LogDebug(...) (void)0 +#else +#define LogDebug(...) __android_log_print(ANDROID_LOG_DEBUG, "KernelSU", __VA_ARGS__) +#endif + +#endif diff --git a/manager/app/src/main/ic_launcher-playstore.png b/manager/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..bba3a14 Binary files /dev/null and b/manager/app/src/main/ic_launcher-playstore.png differ diff --git a/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt b/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt new file mode 100644 index 0000000..2f587d1 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/KernelSUApplication.kt @@ -0,0 +1,72 @@ +package com.sukisu.ultra + +import android.app.Application +import android.system.Os +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import coil.Coil +import coil.ImageLoader +import com.dergoogler.mmrl.platform.Platform +import me.zhanghai.android.appiconloader.coil.AppIconFetcher +import me.zhanghai.android.appiconloader.coil.AppIconKeyer +import okhttp3.Cache +import okhttp3.OkHttpClient +import java.io.File +import java.util.Locale + +lateinit var ksuApp: KernelSUApplication + +class KernelSUApplication : Application(), ViewModelStoreOwner { + + lateinit var okhttpClient: OkHttpClient + private val appViewModelStore by lazy { ViewModelStore() } + + override fun onCreate() { + super.onCreate() + ksuApp = this + + // For faster response when first entering superuser or webui activity + val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java] + CoroutineScope(Dispatchers.Main).launch { + superUserViewModel.fetchAppList() + } + + Platform.setHiddenApiExemptions() + + val context = this + val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size) + Coil.setImageLoader( + ImageLoader.Builder(context) + .components { + add(AppIconKeyer()) + add(AppIconFetcher.Factory(iconSize, false, context)) + } + .build() + ) + + val webroot = File(dataDir, "webroot") + if (!webroot.exists()) { + webroot.mkdir() + } + + // Provide working env for rust's temp_dir() + Os.setenv("TMPDIR", cacheDir.absolutePath, true) + + okhttpClient = + OkHttpClient.Builder().cache(Cache(File(cacheDir, "okhttp"), 10 * 1024 * 1024)) + .addInterceptor { block -> + block.proceed( + block.request().newBuilder() + .header("User-Agent", "SukiSU/${BuildConfig.VERSION_CODE}") + .header("Accept-Language", Locale.getDefault().toLanguageTag()).build() + ) + }.build() + } + override val viewModelStore: ViewModelStore + get() = appViewModelStore +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/Kernels.kt b/manager/app/src/main/java/com/sukisu/ultra/Kernels.kt new file mode 100644 index 0000000..597ac1c --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/Kernels.kt @@ -0,0 +1,32 @@ +package com.sukisu.ultra + +import android.system.Os + +/** + * @author weishu + * @date 2022/12/10. + */ + +data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) { + override fun toString(): String = "$major.$patchLevel.$subLevel" + fun isGKI(): Boolean = when { + major > 5 -> true + major == 5 && patchLevel >= 10 -> true + else -> false + } +} + +fun parseKernelVersion(version: String): KernelVersion { + val find = "(\\d+)\\.(\\d+)\\.(\\d+)".toRegex().find(version) + return if (find != null) { + KernelVersion(find.groupValues[1].toInt(), find.groupValues[2].toInt(), find.groupValues[3].toInt()) + } else { + KernelVersion(-1, -1, -1) + } +} + +fun getKernelVersion(): KernelVersion { + Os.uname().release.let { + return parseKernelVersion(it) + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/Natives.kt b/manager/app/src/main/java/com/sukisu/ultra/Natives.kt new file mode 100644 index 0000000..db891da --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/Natives.kt @@ -0,0 +1,281 @@ +package com.sukisu.ultra + +import android.os.Parcelable +import androidx.annotation.Keep +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize + +/** + * @author weishu + * @date 2022/12/8. + */ +object Natives { + // minimal supported kernel version + // 10915: allowlist breaking change, add app profile + // 10931: app profile struct add 'version' field + // 10946: add capabilities + // 10977: change groups_count and groups to avoid overflow write + // 11071: Fix the issue of failing to set a custom SELinux type. + // 12143: breaking: new supercall impl + const val MINIMAL_SUPPORTED_KERNEL = 12143 + + // 12040: Support disable sucompat mode + const val KERNEL_SU_DOMAIN = "u:r:su:s0" + + const val MINIMAL_SUPPORTED_KERNEL_FULL = "v3.1.8" + + const val MINIMAL_SUPPORTED_KPM = 12800 + + const val MINIMAL_SUPPORTED_DYNAMIC_MANAGER = 13215 + + const val MINIMAL_SUPPORTED_UID_SCANNER = 13347 + + const val MINIMAL_NEW_IOCTL_KERNEL = 13490 + + const val ROOT_UID = 0 + const val ROOT_GID = 0 + + // 获取完整版本号 + external fun getFullVersion(): String + + fun isVersionLessThan(v1Full: String, v2Full: String): Boolean { + fun extractVersionParts(version: String): List { + val match = Regex("""v\d+(\.\d+)*""").find(version) + val simpleVersion = match?.value ?: version + return simpleVersion.trimStart('v').split('.').map { it.toIntOrNull() ?: 0 } + } + + val v1Parts = extractVersionParts(v1Full) + val v2Parts = extractVersionParts(v2Full) + val maxLength = maxOf(v1Parts.size, v2Parts.size) + for (i in 0 until maxLength) { + val num1 = v1Parts.getOrElse(i) { 0 } + val num2 = v2Parts.getOrElse(i) { 0 } + if (num1 != num2) return num1 < num2 + } + return false + } + + fun getSimpleVersionFull(): String = getFullVersion().let { version -> + Regex("""v\d+(\.\d+)*""").find(version)?.value ?: version + } + + init { + System.loadLibrary("zakosign") + System.loadLibrary("kernelsu") + } + + val version: Int + external get + + // get the uid list of allowed su processes. + val allowList: IntArray + external get + + val isSafeMode: Boolean + external get + + val isLkmMode: Boolean + external get + + val isManager: Boolean + external get + + external fun uidShouldUmount(uid: Int): Boolean + + /** + * Get the profile of the given package. + * @param key usually the package name + * @return return null if failed. + */ + external fun getAppProfile(key: String?, uid: Int): Profile + external fun setAppProfile(profile: Profile?): Boolean + + /** + * `su` compat mode can be disabled temporarily. + * 0: disabled + * 1: enabled + * negative : error + */ + external fun isSuEnabled(): Boolean + external fun setSuEnabled(enabled: Boolean): Boolean + + /** + * Kernel module umount can be disabled temporarily. + * 0: disabled + * 1: enabled + * negative : error + */ + external fun isKernelUmountEnabled(): Boolean + external fun setKernelUmountEnabled(enabled: Boolean): Boolean + + /** + * Enhanced security can be enabled/disabled. + * 0: disabled + * 1: enabled + * negative : error + */ + external fun isEnhancedSecurityEnabled(): Boolean + external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean + + /** + * Su Log can be enabled/disabled. + * 0: disabled + * 1: enabled + * negative : error + */ + external fun isSuLogEnabled(): Boolean + external fun setSuLogEnabled(enabled: Boolean): Boolean + + external fun isKPMEnabled(): Boolean + external fun getHookType(): String + + /** + * Get SUSFS feature status from kernel + * @return SusfsFeatureStatus object containing all feature states, or null if failed + */ + + /** + * Set dynamic managerature configuration + * @param size APK signature size + * @param hash APK signature hash (64 character hex string) + * @return true if successful, false otherwise + */ + external fun setDynamicManager(size: Int, hash: String): Boolean + + + /** + * Get current dynamic managerature configuration + * @return DynamicManagerConfig object containing current configuration, or null if not set + */ + external fun getDynamicManager(): DynamicManagerConfig? + + /** + * Clear dynamic managerature configuration + * @return true if successful, false otherwise + */ + external fun clearDynamicManager(): Boolean + + /** + * Get active managers list when dynamic manager is enabled + * @return ManagersList object containing active managers, or null if failed or not enabled + */ + external fun getManagersList(): ManagersList? + + // 模块签名验证 + external fun verifyModuleSignature(modulePath: String): Boolean + + /** + * Check if UID scanner is currently enabled + * @return true if UID scanner is enabled, false otherwise + */ + external fun isUidScannerEnabled(): Boolean + + /** + * Enable or disable UID scanner + * @param enabled true to enable, false to disable + * @return true if operation was successful, false otherwise + */ + external fun setUidScannerEnabled(enabled: Boolean): Boolean + + /** + * Clear UID scanner environment (force exit) + * This will forcefully stop all UID scanner operations and clear the environment + * @return true if operation was successful, false otherwise + */ + external fun clearUidScannerEnvironment(): Boolean + + external fun getUserName(uid: Int): String? + + private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$" + private const val NOBODY_UID = 9999 + + fun setDefaultUmountModules(umountModules: Boolean): Boolean { + Profile( + NON_ROOT_DEFAULT_PROFILE_KEY, + NOBODY_UID, + false, + umountModules = umountModules + ).let { + return setAppProfile(it) + } + } + + fun isDefaultUmountModules(): Boolean { + getAppProfile(NON_ROOT_DEFAULT_PROFILE_KEY, NOBODY_UID).let { + return it.umountModules + } + } + + fun requireNewKernel(): Boolean { + if (version != -1 && version < MINIMAL_SUPPORTED_KERNEL) return true + return isVersionLessThan(getFullVersion(), MINIMAL_SUPPORTED_KERNEL_FULL) + } + + @Immutable + @Parcelize + @Keep + data class DynamicManagerConfig( + val size: Int = 0, + val hash: String = "" + ) : Parcelable { + + fun isValid(): Boolean { + return size > 0 && hash.length == 64 && hash.all { + it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' + } + } + } + + @Immutable + @Parcelize + @Keep + data class ManagersList( + val count: Int = 0, + val managers: List = emptyList() + ) : Parcelable + + @Immutable + @Parcelize + @Keep + data class ManagerInfo( + val uid: Int = 0, + val signatureIndex: Int = 0 + ) : Parcelable + + @Immutable + @Parcelize + @Keep + data class Profile( + // and there is a default profile for root and non-root + val name: String, + // current uid for the package, this is convivent for kernel to check + // if the package name doesn't match uid, then it should be invalidated. + val currentUid: Int = 0, + + // if this is true, kernel will grant root permission to this package + val allowSu: Boolean = false, + + // these are used for root profile + val rootUseDefault: Boolean = true, + val rootTemplate: String? = null, + val uid: Int = ROOT_UID, + val gid: Int = ROOT_GID, + val groups: List = mutableListOf(), + val capabilities: List = mutableListOf(), + val context: String = KERNEL_SU_DOMAIN, + val namespace: Int = Namespace.INHERITED.ordinal, + + val nonRootUseDefault: Boolean = true, + val umountModules: Boolean = true, + var rules: String = "", // this field is save in ksud!! + ) : Parcelable { + enum class Namespace { + INHERITED, + GLOBAL, + INDIVIDUAL, + } + + constructor() : this("") + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/network/RemoteToolsDownloader.kt b/manager/app/src/main/java/com/sukisu/ultra/network/RemoteToolsDownloader.kt new file mode 100644 index 0000000..880b2ea --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/network/RemoteToolsDownloader.kt @@ -0,0 +1,364 @@ +package com.sukisu.ultra.network + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.* +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL +import java.util.concurrent.TimeUnit + +class RemoteToolsDownloader( + private val context: Context, + private val workDir: String +) { + companion object { + private const val TAG = "RemoteToolsDownloader" + + // 远程下载URL配置 + private const val KPTOOLS_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kptools" + private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg" + + // 网络超时配置(毫秒) + private const val CONNECTION_TIMEOUT = 15000 // 15秒连接超时 + private const val READ_TIMEOUT = 30000 // 30秒读取超时 + + // 最大重试次数 + private const val MAX_RETRY_COUNT = 3 + + // 文件校验相关 + private const val MIN_FILE_SIZE = 1024 + } + + interface DownloadProgressListener { + fun onProgress(fileName: String, progress: Int, total: Int) + fun onLog(message: String) + fun onError(fileName: String, error: String) + fun onSuccess(fileName: String, isRemote: Boolean) + } + + data class DownloadResult( + val success: Boolean, + val isRemoteSource: Boolean, + val errorMessage: String? = null + ) + + + suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map = withContext(Dispatchers.IO) { + val results = mutableMapOf() + + listener?.onLog("Starting to prepare KPM tool files...") + + try { + // 确保工作目录存在 + File(workDir).mkdirs() + + // 并行下载两个工具文件 + val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) } + val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) } + + // 等待所有下载完成 + results["kptools"] = kptoolsDeferred.await() + results["kpimg"] = kpimgDeferred.await() + + // 检查kptools执行权限 + val kptoolsFile = File(workDir, "kptools") + if (kptoolsFile.exists()) { + setExecutablePermission(kptoolsFile.absolutePath) + listener?.onLog("Set kptools execution permission") + } + + val successCount = results.values.count { it.success } + val remoteCount = results.values.count { it.success && it.isRemoteSource } + + listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount") + + } catch (e: Exception) { + Log.e(TAG, "Exception occurred while downloading tools", e) + listener?.onLog("Exception occurred during tool download: ${e.message}") + + if (!results.containsKey("kptools")) { + results["kptools"] = downloadSingleTool("kptools", null, listener) + } + if (!results.containsKey("kpimg")) { + results["kpimg"] = downloadSingleTool("kpimg", null, listener) + } + } + + results.toMap() + } + + private suspend fun downloadSingleTool( + fileName: String, + remoteUrl: String?, + listener: DownloadProgressListener? + ): DownloadResult = withContext(Dispatchers.IO) { + + val targetFile = File(workDir, fileName) + + if (remoteUrl == null) { + return@withContext useLocalVersion(fileName, targetFile, listener) + } + + // 尝试从远程下载 + listener?.onLog("Downloading $fileName from remote repository...") + + var lastError = "" + + // 重试机制 + repeat(MAX_RETRY_COUNT) { attempt -> + try { + val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener) + if (result.success) { + listener?.onSuccess(fileName, true) + return@withContext result + } + lastError = result.errorMessage ?: "Unknown error" + + } catch (e: Exception) { + lastError = e.message ?: "Network exception" + Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e) + + if (attempt < MAX_RETRY_COUNT - 1) { + listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...") + delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L)) + } + } + } + + // 所有重试都失败,回退到本地版本 + listener?.onError(fileName, "Remote download failed: $lastError") + listener?.onLog("$fileName remote download failed, falling back to local version...") + + useLocalVersion(fileName, targetFile, listener) + } + + private suspend fun downloadFromRemote( + fileName: String, + remoteUrl: String, + targetFile: File, + listener: DownloadProgressListener? + ): DownloadResult = withContext(Dispatchers.IO) { + + var connection: HttpURLConnection? = null + + try { + val url = URL(remoteUrl) + connection = url.openConnection() as HttpURLConnection + + // 设置连接参数 + connection.apply { + connectTimeout = CONNECTION_TIMEOUT + readTimeout = READ_TIMEOUT + requestMethod = "GET" + setRequestProperty("User-Agent", "SukiSU-KPM-Downloader/1.0") + setRequestProperty("Accept", "*/*") + setRequestProperty("Connection", "close") + } + + // 建立连接 + connection.connect() + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + return@withContext DownloadResult( + false, + isRemoteSource = false, + errorMessage = "HTTP error code: $responseCode" + ) + } + + val fileLength = connection.contentLength + Log.d(TAG, "$fileName remote file size: $fileLength bytes") + + // 创建临时文件 + val tempFile = File(targetFile.absolutePath + ".tmp") + + // 下载文件 + connection.inputStream.use { input -> + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(8192) + var totalBytes = 0 + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + // 检查协程是否被取消 + ensureActive() + + output.write(buffer, 0, bytesRead) + totalBytes += bytesRead + + // 更新下载进度 + if (fileLength > 0) { + listener?.onProgress(fileName, totalBytes, fileLength) + } + } + + output.flush() + } + } + + // 验证下载的文件 + if (!validateDownloadedFile(tempFile, fileName)) { + tempFile.delete() + return@withContext DownloadResult( + success = false, + isRemoteSource = false, + errorMessage = "File verification failed" + ) + } + + // 移动临时文件到目标位置 + if (targetFile.exists()) { + targetFile.delete() + } + + if (!tempFile.renameTo(targetFile)) { + tempFile.delete() + return@withContext DownloadResult( + false, + isRemoteSource = false, + errorMessage = "Failed to move file" + ) + } + + Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes") + listener?.onLog("$fileName remote download successful") + + DownloadResult(true, isRemoteSource = true) + + } catch (e: SocketTimeoutException) { + Log.w(TAG, "$fileName download timeout", e) + DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout") + } catch (e: IOException) { + Log.w(TAG, "$fileName network IO exception", e) + DownloadResult(false, + isRemoteSource = false, + errorMessage = "Network connection exception: ${e.message}" + ) + } catch (e: Exception) { + Log.e(TAG, "$fileName exception occurred during download", e) + DownloadResult(false, + isRemoteSource = false, + errorMessage = "Download exception: ${e.message}" + ) + } finally { + connection?.disconnect() + } + } + + private suspend fun useLocalVersion( + fileName: String, + targetFile: File, + listener: DownloadProgressListener? + ): DownloadResult = withContext(Dispatchers.IO) { + + try { + com.sukisu.ultra.utils.AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath) + + if (!targetFile.exists()) { + val errorMsg = "Local $fileName file extraction failed" + listener?.onError(fileName, errorMsg) + return@withContext DownloadResult(false, + isRemoteSource = false, + errorMessage = errorMsg + ) + } + + if (!validateDownloadedFile(targetFile, fileName)) { + val errorMsg = "Local $fileName file verification failed" + listener?.onError(fileName, errorMsg) + return@withContext DownloadResult( + success = false, + isRemoteSource = false, + errorMessage = errorMsg + ) + } + + Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes") + listener?.onLog("$fileName local version loaded successfully") + listener?.onSuccess(fileName, false) + + DownloadResult(true, isRemoteSource = false) + + } catch (e: Exception) { + Log.e(TAG, "$fileName local version loading failed", e) + val errorMsg = "Local version loading failed: ${e.message}" + listener?.onError(fileName, errorMsg) + DownloadResult(success = false, isRemoteSource = false, errorMessage = errorMsg) + } + } + + private fun validateDownloadedFile(file: File, fileName: String): Boolean { + if (!file.exists()) { + Log.w(TAG, "$fileName file does not exist") + return false + } + + val fileSize = file.length() + if (fileSize < MIN_FILE_SIZE) { + Log.w(TAG, "$fileName file is too small: $fileSize bytes") + return false + } + + try { + file.inputStream().use { input -> + val header = ByteArray(4) + val bytesRead = input.read(header) + + if (bytesRead < 4) { + Log.w(TAG, "$fileName file header read incomplete") + return false + } + + val isELF = header[0] == 0x7F.toByte() && + header[1] == 'E'.code.toByte() && + header[2] == 'L'.code.toByte() && + header[3] == 'F'.code.toByte() + + if (fileName == "kptools" && !isELF) { + Log.w(TAG, "kptools file format is invalid, not ELF format") + return false + } + + Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF") + return true + } + } catch (e: Exception) { + Log.w(TAG, "$fileName file verification exception", e) + return false + } + } + + private fun setExecutablePermission(filePath: String) { + try { + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath")) + process.waitFor() + Log.d(TAG, "Set execution permission for $filePath") + } catch (e: Exception) { + Log.w(TAG, "Failed to set execution permission: $filePath", e) + try { + File(filePath).setExecutable(true, false) + } catch (ex: Exception) { + Log.w(TAG, "Java method to set permissions also failed", ex) + } + } + } + + + fun cleanup() { + try { + File(workDir).listFiles()?.forEach { file -> + if (file.name.endsWith(".tmp")) { + file.delete() + Log.d(TAG, "Cleaned temporary file: ${file.name}") + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to clean temporary files", e) + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/profile/Capabilities.kt b/manager/app/src/main/java/com/sukisu/ultra/profile/Capabilities.kt new file mode 100644 index 0000000..d44913b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/profile/Capabilities.kt @@ -0,0 +1,49 @@ +package com.sukisu.ultra.profile + +/** + * @author weishu + * @date 2023/6/3. + */ +enum class Capabilities(val cap: Int, val display: String, val desc: String) { + CAP_CHOWN(0, "CHOWN", "Make arbitrary changes to file UIDs and GIDs (see chown(2))"), + CAP_DAC_OVERRIDE(1, "DAC_OVERRIDE", "Bypass file read, write, and execute permission checks"), + CAP_DAC_READ_SEARCH(2, "DAC_READ_SEARCH", "Bypass file read permission checks and directory read and execute permission checks"), + CAP_FOWNER(3, "FOWNER", "Bypass permission checks on operations that normally require the filesystem UID of the process to match the UID of the file (e.g., chmod(2), utime(2)), excluding those operations covered by CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH"), + CAP_FSETID(4, "FSETID", "Don’t clear set-user-ID and set-group-ID permission bits when a file is modified; set the set-group-ID bit for a file whose GID does not match the filesystem or any of the supplementary GIDs of the calling process"), + CAP_KILL(5, "KILL", "Bypass permission checks for sending signals (see kill(2))."), + CAP_SETGID(6, "SETGID", "Make arbitrary manipulations of process GIDs and supplementary GID list; allow setgid(2) manipulation of the caller’s effective and real group IDs"), + CAP_SETUID(7, "SETUID", "Make arbitrary manipulations of process UIDs (setuid(2), setreuid(2), setresuid(2), setfsuid(2)); allow changing the current process user IDs; allow changing of the current process group ID to any value in the system’s range of legal group IDs"), + CAP_SETPCAP(8, "SETPCAP", "If file capabilities are supported: grant or remove any capability in the caller’s permitted capability set to or from any other process. (This property supersedes the obsolete notion of giving a process all capabilities by granting all capabilities in its permitted set, and of removing all capabilities from a process by granting no capabilities in its permitted set. It does not permit any actions that were not permitted before.)"), + CAP_LINUX_IMMUTABLE(9, "LINUX_IMMUTABLE", "Set the FS_APPEND_FL and FS_IMMUTABLE_FL inode flags (see chattr(1))."), + CAP_NET_BIND_SERVICE(10, "NET_BIND_SERVICE", "Bind a socket to Internet domain"), + CAP_NET_BROADCAST(11, "NET_BROADCAST", "Make socket broadcasts, and listen to multicasts"), + CAP_NET_ADMIN(12, "NET_ADMIN", "Perform various network-related operations: interface configuration, administration of IP firewall, masquerading, and accounting, modify routing tables, bind to any address for transparent proxying, set type-of-service (TOS), clear driver statistics, set promiscuous mode, enabling multicasting, use setsockopt(2) to set the following socket options: SO_DEBUG, SO_MARK, SO_PRIORITY (for a priority outside the range 0 to 6), SO_RCVBUFFORCE, and SO_SNDBUFFORCE"), + CAP_NET_RAW(13, "NET_RAW", "Use RAW and PACKET sockets"), + CAP_IPC_LOCK(14, "IPC_LOCK", "Lock memory (mlock(2), mlockall(2), mmap(2), shmctl(2))"), + CAP_IPC_OWNER(15, "IPC_OWNER", "Bypass permission checks for operations on System V IPC objects"), + CAP_SYS_MODULE(16, "SYS_MODULE", "Load and unload kernel modules (see init_module(2) and delete_module(2)); in kernels before 2.6.25, this also granted rights for various other operations related to kernel modules"), + CAP_SYS_RAWIO(17, "SYS_RAWIO", "Perform I/O port operations (iopl(2) and ioperm(2)); access /proc/kcore"), + CAP_SYS_CHROOT(18, "SYS_CHROOT", "Use chroot(2)"), + CAP_SYS_PTRACE(19, "SYS_PTRACE", "Trace arbitrary processes using ptrace(2)"), + CAP_SYS_PACCT(20, "SYS_PACCT", "Use acct(2)"), + CAP_SYS_ADMIN(21, "SYS_ADMIN", "Perform a range of system administration operations including: quotactl(2), mount(2), umount(2), swapon(2), swapoff(2), sethostname(2), and setdomainname(2); set and modify process resource limits (setrlimit(2)); perform various network-related operations (e.g., setting privileged socket options, enabling multicasting, interface configuration); perform various IPC operations (e.g., SysV semaphores, POSIX message queues, System V shared memory); allow reboot and kexec_load(2); override /proc/sys kernel tunables; perform ptrace(2) PTRACE_SECCOMP_GET_FILTER operation; perform some tracing and debugging operations (see ptrace(2)); administer the lifetime of kernel tracepoints (tracefs(5)); perform the KEYCTL_CHOWN and KEYCTL_SETPERM keyctl(2) operations; perform the following keyctl(2) operations: KEYCTL_CAPABILITIES, KEYCTL_CAPSQUASH, and KEYCTL_PKEY_ OPERATIONS; set state for the Extensible Authentication Protocol (EAP) kernel module; and override the RLIMIT_NPROC resource limit; allow ioperm/iopl access to I/O ports"), + CAP_SYS_BOOT(22, "SYS_BOOT", "Use reboot(2) and kexec_load(2), reboot and load a new kernel for later execution"), + CAP_SYS_NICE(23, "SYS_NICE", "Raise process nice value (nice(2), setpriority(2)) and change the nice value for arbitrary processes; set real-time scheduling policies for calling process, and set scheduling policies and priorities for arbitrary processes (sched_setscheduler(2), sched_setparam(2)"), + CAP_SYS_RESOURCE(24, "SYS_RESOURCE", "Override resource Limits. Set resource limits (setrlimit(2), prlimit(2)), override quota limits (quota(2), quotactl(2)), override reserved space on ext2 filesystem (ext2_ioctl(2)), override size restrictions on IPC message queues (msg(2)) and system V shared memory segments (shmget(2)), and override the /proc/sys/fs/pipe-size-max limit"), + CAP_SYS_TIME(25, "SYS_TIME", "Set system clock (settimeofday(2), stime(2), adjtimex(2)); set real-time (hardware) clock"), + CAP_SYS_TTY_CONFIG(26, "SYS_TTY_CONFIG", "Use vhangup(2); employ various privileged ioctl(2) operations on virtual terminals"), + CAP_MKNOD(27, "MKNOD", "Create special files using mknod(2)"), + CAP_LEASE(28, "LEASE", "Establish leases on arbitrary files (see fcntl(2))"), + CAP_AUDIT_WRITE(29, "AUDIT_WRITE", "Write records to kernel auditing log"), + CAP_AUDIT_CONTROL(30, "AUDIT_CONTROL", "Enable and disable kernel auditing; change auditing filter rules; retrieve auditing status and filtering rules"), + CAP_SETFCAP(31, "SETFCAP", "If file capabilities are supported: grant or remove any capability in any capability set to any file"), + CAP_MAC_OVERRIDE(32, "MAC_OVERRIDE", "Override Mandatory Access Control (MAC). Implemented for the Smack Linux Security Module (LSM)"), + CAP_MAC_ADMIN(33, "MAC_ADMIN", "Allow MAC configuration or state changes. Implemented for the Smack LSM"), + CAP_SYSLOG(34, "SYSLOG", "Perform privileged syslog(2) operations. See syslog(2) for information on which operations require privilege"), + CAP_WAKE_ALARM(35, "WAKE_ALARM", "Trigger something that will wake up the system"), + CAP_BLOCK_SUSPEND(36, "BLOCK_SUSPEND", "Employ features that can block system suspend"), + CAP_AUDIT_READ(37, "AUDIT_READ", "Allow reading the audit log via a multicast netlink socket"), + CAP_PERFMON(38, "PERFMON", "Allow performance monitoring via perf_event_open(2)"), + CAP_BPF(39, "BPF", "Allow BPF operations via bpf(2)"), + CAP_CHECKPOINT_RESTORE(40, "CHECKPOINT_RESTORE", "Allow processes to be checkpointed via checkpoint/restore in user namespace(2)"), +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/profile/Groups.kt b/manager/app/src/main/java/com/sukisu/ultra/profile/Groups.kt new file mode 100644 index 0000000..2ba73ba --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/profile/Groups.kt @@ -0,0 +1,130 @@ +package com.sukisu.ultra.profile + +/** + * https://cs.android.com/android/platform/superproject/main/+/main:system/core/libcutils/include/private/android_filesystem_config.h + * @author weishu + * @date 2023/6/3. + */ +enum class Groups(val gid: Int, val display: String, val desc: String) { + ROOT(0, "root", "traditional unix root user"), + DAEMON(1, "daemon", "Traditional unix daemon owner."), + BIN(2, "bin", "Traditional unix binaries owner."), + SYS(3, "sys", "A group with the same gid on Linux/macOS/Android."), + SYSTEM(1000, "system", "system server"), + RADIO(1001, "radio", "telephony subsystem, RIL"), + BLUETOOTH(1002, "bluetooth", "bluetooth subsystem"), + GRAPHICS(1003, "graphics", "graphics devices"), + INPUT(1004, "input", "input devices"), + AUDIO(1005, "audio", "audio devices"), + CAMERA(1006, "camera", "camera devices"), + LOG(1007, "log", "log devices"), + COMPASS(1008, "compass", "compass device"), + MOUNT(1009, "mount", "mountd socket"), + WIFI(1010, "wifi", "wifi subsystem"), + ADB(1011, "adb", "android debug bridge (adbd)"), + INSTALL(1012, "install", "group for installing packages"), + MEDIA(1013, "media", "mediaserver process"), + DHCP(1014, "dhcp", "dhcp client"), + SDCARD_RW(1015, "sdcard_rw", "external storage write access"), + VPN(1016, "vpn", "vpn system"), + KEYSTORE(1017, "keystore", "keystore subsystem"), + USB(1018, "usb", "USB devices"), + DRM(1019, "drm", "DRM server"), + MDNSR(1020, "mdnsr", "MulticastDNSResponder (service discovery)"), + GPS(1021, "gps", "GPS daemon"), + UNUSED1(1022, "unused1", "deprecated, DO NOT USE"), + MEDIA_RW(1023, "media_rw", "internal media storage write access"), + MTP(1024, "mtp", "MTP USB driver access"), + UNUSED2(1025, "unused2", "deprecated, DO NOT USE"), + DRMRPC(1026, "drmrpc", "group for drm rpc"), + NFC(1027, "nfc", "nfc subsystem"), + SDCARD_R(1028, "sdcard_r", "external storage read access"), + CLAT(1029, "clat", "clat part of nat464"), + LOOP_RADIO(1030, "loop_radio", "loop radio devices"), + MEDIA_DRM(1031, "media_drm", "MediaDrm plugins"), + PACKAGE_INFO(1032, "package_info", "access to installed package details"), + SDCARD_PICS(1033, "sdcard_pics", "external storage photos access"), + SDCARD_AV(1034, "sdcard_av", "external storage audio/video access"), + SDCARD_ALL(1035, "sdcard_all", "access all users external storage"), + LOGD(1036, "logd", "log daemon"), + SHARED_RELRO(1037, "shared_relro", "creator of shared GNU RELRO files"), + DBUS(1038, "dbus", "dbus-daemon IPC broker process"), + TLSDATE(1039, "tlsdate", "tlsdate unprivileged user"), + MEDIA_EX(1040, "media_ex", "mediaextractor process"), + AUDIOSERVER(1041, "audioserver", "audioserver process"), + METRICS_COLL(1042, "metrics_coll", "metrics_collector process"), + METRICSD(1043, "metricsd", "metricsd process"), + WEBSERV(1044, "webserv", "webservd process"), + DEBUGGERD(1045, "debuggerd", "debuggerd unprivileged user"), + MEDIA_CODEC(1046, "media_codec", "media_codec process"), + CAMERASERVER(1047, "cameraserver", "cameraserver process"), + FIREWALL(1048, "firewall", "firewall process"), + TRUNKS(1049, "trunks", "trunksd process"), + NVRAM(1050, "nvram", "nvram daemon"), + DNS(1051, "dns", "DNS resolution daemon (system: netd)"), + DNS_TETHER(1052, "dns_tether", "DNS resolution daemon (tether: dnsmasq)"), + WEBVIEW_ZYGOTE(1053, "webview_zygote", "WebView zygote process"), + VEHICLE_NETWORK(1054, "vehicle_network", "Vehicle network service"), + MEDIA_AUDIO(1055, "media_audio", "GID for audio files on internal media storage"), + MEDIA_VIDEO(1056, "media_video", "GID for video files on internal media storage"), + MEDIA_IMAGE(1057, "media_image", "GID for image files on internal media storage"), + TOMBSTONED(1058, "tombstoned", "tombstoned user"), + MEDIA_OBB(1059, "media_obb", "GID for OBB files on internal media storage"), + ESE(1060, "ese", "embedded secure element (eSE) subsystem"), + OTA_UPDATE(1061, "ota_update", "resource tracking UID for OTA updates"), + AUTOMOTIVE_EVS(1062, "automotive_evs", "Automotive rear and surround view system"), + LOWPAN(1063, "lowpan", "LoWPAN subsystem"), + HSM(1064, "lowpan", "hardware security module subsystem"), + RESERVED_DISK(1065, "reserved_disk", "GID that has access to reserved disk space"), + STATSD(1066, "statsd", "statsd daemon"), + INCIDENTD(1067, "incidentd", "incidentd daemon"), + SECURE_ELEMENT(1068, "secure_element", "secure element subsystem"), + LMKD(1069, "lmkd", "low memory killer daemon"), + LLKD(1070, "llkd", "live lock daemon"), + IORAPD(1071, "iorapd", "input/output readahead and pin daemon"), + GPU_SERVICE(1072, "gpu_service", "GPU service daemon"), + NETWORK_STACK(1073, "network_stack", "network stack service"), + GSID(1074, "GSID", "GSI service daemon"), + FSVERITY_CERT(1075, "fsverity_cert", "fs-verity key ownership in keystore"), + CREDSTORE(1076, "credstore", "identity credential manager service"), + EXTERNAL_STORAGE(1077, "external_storage", "Full external storage access including USB OTG volumes"), + EXT_DATA_RW(1078, "ext_data_rw", "GID for app-private data directories on external storage"), + EXT_OBB_RW(1079, "ext_obb_rw", "GID for OBB directories on external storage"), + CONTEXT_HUB(1080, "context_hub", "GID for access to the Context Hub"), + VIRTUALIZATIONSERVICE(1081, "virtualizationservice", "VirtualizationService daemon"), + ARTD(1082, "artd", "ART Service daemon"), + UWB(1083, "uwb", "UWB subsystem"), + THREAD_NETWORK(1084, "thread_network", "Thread Network subsystem"), + DICED(1085, "diced", "Android's DICE daemon"), + DMESGD(1086, "dmesgd", "dmesg parsing daemon for kernel report collection"), + JC_WEAVER(1087, "jc_weaver", "Javacard Weaver HAL - to manage omapi ARA rules"), + JC_STRONGBOX(1088, "jc_strongbox", "Javacard Strongbox HAL - to manage omapi ARA rules"), + JC_IDENTITYCRED(1089, "jc_identitycred", "Javacard Identity Cred HAL - to manage omapi ARA rules"), + SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"), + SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"), + PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"), + + SHELL(2000, "shell", "adb and debug shell user"), + CACHE(2001, "cache", "cache access"), + DIAG(2002, "diag", "access to diagnostic resources"), + + /* The 3000 series are intended for use as supplemental group id's only. + * They indicate special Android capabilities that the kernel is aware of. */ + NET_BT_ADMIN(3001, "net_bt_admin", "bluetooth: create any socket"), + NET_BT(3002, "net_bt", "bluetooth: create sco, rfcomm or l2cap sockets"), + INET(3003, "inet", "can create AF_INET and AF_INET6 sockets"), + NET_RAW(3004, "net_raw", "can create raw INET sockets"), + NET_ADMIN(3005, "net_admin", "can configure interfaces and routing tables."), + NET_BW_STATS(3006, "net_bw_stats", "read bandwidth statistics"), + NET_BW_ACCT(3007, "net_bw_acct", "change bandwidth statistics accounting"), + NET_BT_STACK(3008, "net_bt_stack", "access to various bluetooth management functions"), + READPROC(3009, "readproc", "Allow /proc read access"), + WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"), + UHID(3011, "uhid", "Allow read/write to /dev/uhid node"), + READTRACEFS(3012, "readtracefs", "Allow tracefs read"), + + EVERYBODY(9997, "everybody", "Shared external storage read/write"), + MISC(9998, "misc", "Access to misc storage"), + NOBODY(9999, "nobody", "Reserved"), + APP(10000, "app", "Access to app data"), +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/KsuService.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/KsuService.kt new file mode 100644 index 0000000..39201e5 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/KsuService.kt @@ -0,0 +1,75 @@ +package com.sukisu.ultra.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageInfo +import android.os.* +import android.util.Log +import com.topjohnwu.superuser.ipc.RootService +import com.sukisu.zako.IKsuInterface + +/** + * @author ShirkNeko + * @date 2025/10/17. + */ +class KsuService : RootService() { + + private val TAG = "KsuService" + + private val cacheLock = Object() + private var _all: List? = null + private val allPackages: List + get() = synchronized(cacheLock) { + _all ?: loadAllPackages().also { _all = it } + } + + private fun loadAllPackages(): List { + val tmp = arrayListOf() + for (user in (getSystemService(USER_SERVICE) as UserManager).userProfiles) { + val userId = user.getUserIdCompat() + tmp += getInstalledPackagesAsUser(userId) + } + return tmp + } + + internal inner class Stub : IKsuInterface.Stub() { + override fun getPackageCount(): Int = allPackages.size + + override fun getPackages(start: Int, maxCount: Int): List { + val list = allPackages + val end = (start + maxCount).coerceAtMost(list.size) + return if (start >= list.size) emptyList() + else list.subList(start, end) + } + } + + override fun onBind(intent: Intent): IBinder = Stub() + + @SuppressLint("PrivateApi") + private fun getInstalledPackagesAsUser(userId: Int): List { + return try { + val pm = packageManager + val m = pm.javaClass.getDeclaredMethod( + "getInstalledPackagesAsUser", + Int::class.java, + Int::class.java + ) + @Suppress("UNCHECKED_CAST") + m.invoke(pm, 0, userId) as List + } catch (e: Throwable) { + Log.e(TAG, "getInstalledPackagesAsUser", e) + emptyList() + } + } + + private fun UserHandle.getUserIdCompat(): Int { + return try { + javaClass.getDeclaredField("identifier").apply { isAccessible = true }.getInt(this) + } catch (_: NoSuchFieldException) { + javaClass.getDeclaredMethod("getIdentifier").invoke(this) as Int + } catch (e: Throwable) { + Log.e("KsuService", "getUserIdCompat", e) + 0 + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt new file mode 100644 index 0000000..033ee44 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt @@ -0,0 +1,307 @@ +package com.sukisu.ultra.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle +import com.ramcosta.composedestinations.generated.NavGraphs +import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination +import com.ramcosta.composedestinations.spec.NavHostGraphSpec +import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator +import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ui.screen.BottomBarDestination +import com.sukisu.ultra.ui.theme.KernelSUTheme +import com.sukisu.ultra.ui.util.LocalSnackbarHost +import com.sukisu.ultra.ui.util.install +import com.sukisu.ultra.ui.viewmodel.HomeViewModel +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import com.sukisu.ultra.ui.webui.initPlatform +import com.sukisu.ultra.ui.component.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import com.sukisu.ultra.ui.activity.component.BottomBar +import com.sukisu.ultra.ui.activity.util.* + +class MainActivity : ComponentActivity() { + private lateinit var superUserViewModel: SuperUserViewModel + private lateinit var homeViewModel: HomeViewModel + internal val settingsStateFlow = MutableStateFlow(SettingsState()) + + data class SettingsState( + val isHideOtherInfo: Boolean = false, + val showKpmInfo: Boolean = false + ) + + private var showConfirmationDialog = mutableStateOf(false) + private var pendingZipFiles = mutableStateOf>(emptyList()) + + private lateinit var themeChangeObserver: ThemeChangeContentObserver + private var isInitialized = false + + override fun attachBaseContext(newBase: Context?) { + super.attachBaseContext(newBase?.let { LocaleHelper.applyLanguage(it) }) + } + + override fun onCreate(savedInstanceState: Bundle?) { + try { + // 应用自定义 DPI + DisplayUtils.applyCustomDpi(this) + + // Enable edge to edge + enableEdgeToEdge() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + + super.onCreate(savedInstanceState) + + val isManager = Natives.isManager + if (isManager && !Natives.requireNewKernel()) { + install() + } + + // 使用标记控制初始化流程 + if (!isInitialized) { + initializeViewModels() + initializeData() + isInitialized = true + } + + // Check if launched with a ZIP file + val zipUri: ArrayList? = when (intent?.action) { + Intent.ACTION_SEND -> { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + uri?.let { arrayListOf(it) } + } + + Intent.ACTION_SEND_MULTIPLE -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + } + + else -> when { + intent?.data != null -> arrayListOf(intent.data!!) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + intent.getParcelableArrayListExtra("uris", Uri::class.java) + } + else -> { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra("uris") + } + } + } + + setContent { + KernelSUTheme { + val navController = rememberNavController() + val snackBarHostState = remember { SnackbarHostState() } + val currentDestination = navController.currentBackStackEntryAsState().value?.destination + + val bottomBarRoutes = remember { + BottomBarDestination.entries.map { it.direction.route }.toSet() + } + + val navigator = navController.rememberDestinationsNavigator() + + InstallConfirmationDialog( + show = showConfirmationDialog.value, + zipFiles = pendingZipFiles.value, + onConfirm = { confirmedFiles -> + showConfirmationDialog.value = false + UltraActivityUtils.navigateToFlashScreen(this, confirmedFiles, navigator) + }, + onDismiss = { + showConfirmationDialog.value = false + pendingZipFiles.value = emptyList() + finish() + } + ) + + LaunchedEffect(zipUri) { + if (!zipUri.isNullOrEmpty()) { + // 检测 ZIP 文件类型并显示确认对话框 + lifecycleScope.launch { + UltraActivityUtils.detectZipTypeAndShowConfirmation(this@MainActivity, zipUri) { infos -> + if (infos.isNotEmpty()) { + pendingZipFiles.value = infos + showConfirmationDialog.value = true + } else { + finish() + } + } + } + } + } + + val showBottomBar = when (currentDestination?.route) { + ExecuteModuleActionScreenDestination.route -> false + else -> true + } + + LaunchedEffect(Unit) { + initPlatform() + } + + CompositionLocalProvider( + LocalSnackbarHost provides snackBarHostState + ) { + Scaffold( + bottomBar = { + AnimatedBottomBar.AnimatedBottomBarWrapper( + showBottomBar = showBottomBar, + content = { BottomBar(navController) } + ) + }, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { innerPadding -> + DestinationsNavHost( + modifier = Modifier.padding(innerPadding), + navGraph = NavGraphs.root as NavHostGraphSpec, + navController = navController, + defaultTransitions = object : NavHostAnimatedDestinationStyle() { + override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + // If the target is a detail page (not a bottom navigation page), slide in from the right + if (targetState.destination.route !in bottomBarRoutes) { + slideInHorizontally(initialOffsetX = { it }) + } else { + // Otherwise (switching between bottom navigation pages), use fade in + fadeIn(animationSpec = tween(340)) + } + } + + override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + // If navigating from the home page (bottom navigation page) to a detail page, slide out to the left + if (initialState.destination.route in bottomBarRoutes && targetState.destination.route !in bottomBarRoutes) { + slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut() + } else { + // Otherwise (switching between bottom navigation pages), use fade out + fadeOut(animationSpec = tween(340)) + } + } + + override val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + // If returning to the home page (bottom navigation page), slide in from the left + if (targetState.destination.route in bottomBarRoutes) { + slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn() + } else { + // Otherwise (e.g., returning between multiple detail pages), use default fade in + fadeIn(animationSpec = tween(340)) + } + } + + override val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + // If returning from a detail page (not a bottom navigation page), scale down and fade out + if (initialState.destination.route !in bottomBarRoutes) { + scaleOut(targetScale = 0.9f) + fadeOut() + } else { + // Otherwise, use default fade out + fadeOut(animationSpec = tween(340)) + } + } + } + ) + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun initializeViewModels() { + superUserViewModel = SuperUserViewModel() + homeViewModel = HomeViewModel() + + // 设置主题变化监听器 + themeChangeObserver = ThemeUtils.registerThemeChangeObserver(this) + } + + private fun initializeData() { + lifecycleScope.launch { + try { + superUserViewModel.fetchAppList() + } catch (e: Exception) { + e.printStackTrace() + } + } + + // 数据刷新协程 + DataRefreshUtils.startDataRefreshCoroutine(lifecycleScope) + DataRefreshUtils.startSettingsMonitorCoroutine(lifecycleScope, this, settingsStateFlow) + + // 初始化主题相关设置 + ThemeUtils.initializeThemeSettings(this, settingsStateFlow) + } + + override fun onResume() { + try { + super.onResume() + ThemeUtils.onActivityResume() + + // 仅在需要时刷新数据 + if (isInitialized) { + refreshData() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun refreshData() { + lifecycleScope.launch { + try { + superUserViewModel.fetchAppList() + DataRefreshUtils.refreshData(lifecycleScope) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + override fun onPause() { + try { + super.onPause() + ThemeUtils.onActivityPause(this) + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun onDestroy() { + try { + ThemeUtils.unregisterThemeChangeObserver(this, themeChangeObserver) + super.onDestroy() + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/activity/component/BottomBar.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/activity/component/BottomBar.kt new file mode 100644 index 0000000..7efffad --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/activity/component/BottomBar.kt @@ -0,0 +1,219 @@ +package com.sukisu.ultra.ui.activity.component + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavHostController +import com.ramcosta.composedestinations.generated.NavGraphs +import com.ramcosta.composedestinations.spec.RouteOrDirection +import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState +import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ui.MainActivity +import com.sukisu.ultra.ui.activity.util.* +import com.sukisu.ultra.ui.activity.util.AppData.getKpmVersionUse +import com.sukisu.ultra.ui.screen.BottomBarDestination +import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha +import com.sukisu.ultra.ui.theme.CardConfig.cardElevation +import com.sukisu.ultra.ui.util.* + +@SuppressLint("ContextCastToActivity") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomBar(navController: NavHostController) { + val navigator = navController.rememberDestinationsNavigator() + val isFullFeatured = AppData.isFullFeatured() + val kpmVersion = getKpmVersionUse() + val cardColor = MaterialTheme.colorScheme.surfaceContainer + val activity = LocalContext.current as MainActivity + val settings by activity.settingsStateFlow.collectAsState() + + // 检查是否隐藏红点 + val isHideOtherInfo = settings.isHideOtherInfo + val showKpmInfo = settings.showKpmInfo + + // 收集计数数据 + val superuserCount by AppData.DataRefreshManager.superuserCount.collectAsState() + val moduleCount by AppData.DataRefreshManager.moduleCount.collectAsState() + val kpmModuleCount by AppData.DataRefreshManager.kpmModuleCount.collectAsState() + + + NavigationBar( + modifier = Modifier.windowInsetsPadding( + WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + ), + containerColor = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ).containerColor, + tonalElevation = cardElevation + ) { + BottomBarDestination.entries.forEach { destination -> + if (destination == BottomBarDestination.Kpm) { + if (kpmVersion.isNotEmpty() && !kpmVersion.startsWith("Error") && !showKpmInfo && Natives.version >= Natives.MINIMAL_SUPPORTED_KPM) { + if (!isFullFeatured && destination.rootRequired) return@forEach + val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction) + NavigationBarItem( + selected = isCurrentDestOnBackStack, + onClick = { + if (!isCurrentDestOnBackStack) { + navigator.popBackStack(destination.direction, false) + } + navigator.navigate(destination.direction) { + popUpTo(NavGraphs.root as RouteOrDirection) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + BadgedBox( + badge = { + if (kpmModuleCount > 0 && !isHideOtherInfo) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary + ) { + Text( + text = kpmModuleCount.toString(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + ) { + if (isCurrentDestOnBackStack) { + Icon(destination.iconSelected, stringResource(destination.label)) + } else { + Icon(destination.iconNotSelected, stringResource(destination.label)) + } + } + }, + label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) }, + alwaysShowLabel = false + ) + } + } else if (destination == BottomBarDestination.SuperUser) { + if (!isFullFeatured && destination.rootRequired) return@forEach + val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction) + + NavigationBarItem( + selected = isCurrentDestOnBackStack, + onClick = { + if (isCurrentDestOnBackStack) { + navigator.popBackStack(destination.direction, false) + } + navigator.navigate(destination.direction) { + popUpTo(NavGraphs.root) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + BadgedBox( + badge = { + if (superuserCount > 0 && !isHideOtherInfo) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary + ) { + Text( + text = superuserCount.toString(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + ) { + if (isCurrentDestOnBackStack) { + Icon(destination.iconSelected, stringResource(destination.label)) + } else { + Icon(destination.iconNotSelected, stringResource(destination.label)) + } + } + }, + label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) }, + alwaysShowLabel = false + ) + } else if (destination == BottomBarDestination.Module) { + if (!isFullFeatured && destination.rootRequired) return@forEach + val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction) + + NavigationBarItem( + selected = isCurrentDestOnBackStack, + onClick = { + if (isCurrentDestOnBackStack) { + navigator.popBackStack(destination.direction, false) + } + navigator.navigate(destination.direction) { + popUpTo(NavGraphs.root) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + BadgedBox( + badge = { + if (moduleCount > 0 && !isHideOtherInfo) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary) + { + Text( + text = moduleCount.toString(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + ) { + if (isCurrentDestOnBackStack) { + Icon(destination.iconSelected, stringResource(destination.label)) + } else { + Icon(destination.iconNotSelected, stringResource(destination.label)) + } + } + }, + label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) }, + alwaysShowLabel = false + ) + } else { + if (!isFullFeatured && destination.rootRequired) return@forEach + val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction) + + NavigationBarItem( + selected = isCurrentDestOnBackStack, + onClick = { + if (isCurrentDestOnBackStack) { + navigator.popBackStack(destination.direction, false) + } + navigator.navigate(destination.direction) { + popUpTo(NavGraphs.root) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + if (isCurrentDestOnBackStack) { + Icon(destination.iconSelected, stringResource(destination.label)) + } else { + Icon(destination.iconNotSelected, stringResource(destination.label)) + } + }, + label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) }, + alwaysShowLabel = false + ) + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/activity/util/ThemeUtils.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/activity/util/ThemeUtils.kt new file mode 100644 index 0000000..6786917 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/activity/util/ThemeUtils.kt @@ -0,0 +1,97 @@ +package com.sukisu.ultra.ui.activity.util + +import android.content.Context +import android.database.ContentObserver +import android.os.Handler +import android.provider.Settings +import androidx.core.content.edit +import com.sukisu.ultra.ui.MainActivity +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.ThemeConfig +import kotlinx.coroutines.flow.MutableStateFlow + +class ThemeChangeContentObserver( + handler: Handler, + private val onThemeChanged: () -> Unit +) : ContentObserver(handler) { + override fun onChange(selfChange: Boolean) { + super.onChange(selfChange) + onThemeChanged() + } +} + +object ThemeUtils { + + fun initializeThemeSettings(activity: MainActivity, settingsStateFlow: MutableStateFlow) { + val prefs = activity.getSharedPreferences("settings", Context.MODE_PRIVATE) + val isFirstRun = prefs.getBoolean("is_first_run", true) + + settingsStateFlow.value = MainActivity.SettingsState( + isHideOtherInfo = prefs.getBoolean("is_hide_other_info", false), + showKpmInfo = prefs.getBoolean("show_kpm_info", false) + ) + + if (isFirstRun) { + ThemeConfig.preventBackgroundRefresh = false + activity.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit { + putBoolean("prevent_background_refresh", false) + } + prefs.edit { putBoolean("is_first_run", false) } + } + + // 加载保存的背景设置 + loadThemeMode() + loadThemeColors() + loadDynamicColorState() + CardConfig.load(activity.applicationContext) + } + + fun registerThemeChangeObserver(activity: MainActivity): ThemeChangeContentObserver { + val contentObserver = ThemeChangeContentObserver(Handler(activity.mainLooper)) { + activity.runOnUiThread { + if (!ThemeConfig.preventBackgroundRefresh) { + ThemeConfig.backgroundImageLoaded = false + loadCustomBackground() + } + } + } + + activity.contentResolver.registerContentObserver( + Settings.System.getUriFor("ui_night_mode"), + false, + contentObserver + ) + + return contentObserver + } + + fun unregisterThemeChangeObserver(activity: MainActivity, observer: ThemeChangeContentObserver) { + activity.contentResolver.unregisterContentObserver(observer) + } + + fun onActivityPause(activity: MainActivity) { + CardConfig.save(activity.applicationContext) + activity.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit { + putBoolean("prevent_background_refresh", true) + } + ThemeConfig.preventBackgroundRefresh = true + } + + fun onActivityResume() { + if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) { + loadCustomBackground() + } + } + + private fun loadThemeMode() { + } + + private fun loadThemeColors() { + } + + private fun loadDynamicColorState() { + } + + private fun loadCustomBackground() { + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/activity/util/UltraActivityUtils.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/activity/util/UltraActivityUtils.kt new file mode 100644 index 0000000..367e791 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/activity/util/UltraActivityUtils.kt @@ -0,0 +1,236 @@ +package com.sukisu.ultra.ui.activity.util + +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.Composable +import androidx.lifecycle.LifecycleCoroutineScope +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ui.MainActivity +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.* +import android.net.Uri +import androidx.lifecycle.lifecycleScope +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.ui.component.ZipFileDetector +import com.sukisu.ultra.ui.component.ZipFileInfo +import com.sukisu.ultra.ui.component.ZipType +import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.generated.destinations.InstallScreenDestination +import com.sukisu.ultra.ui.screen.FlashIt +import kotlinx.coroutines.withContext +import androidx.core.content.edit + +object AnimatedBottomBar { + @Composable + fun AnimatedBottomBarWrapper( + showBottomBar: Boolean, + content: @Composable () -> Unit + ) { + AnimatedVisibility( + visible = showBottomBar, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + content() + } + } +} + +object UltraActivityUtils { + + suspend fun detectZipTypeAndShowConfirmation( + activity: MainActivity, + zipUris: ArrayList, + onResult: (List) -> Unit + ) { + val infos = ZipFileDetector.detectAndParseZipFiles(activity, zipUris) + withContext(Dispatchers.Main) { onResult(infos) } + } + + fun navigateToFlashScreen( + activity: MainActivity, + zipFiles: List, + navigator: DestinationsNavigator + ) { + activity.lifecycleScope.launch { + val moduleUris = zipFiles.filter { it.type == ZipType.MODULE }.map { it.uri } + val kernelUris = zipFiles.filter { it.type == ZipType.KERNEL }.map { it.uri } + + when { + kernelUris.isNotEmpty() && moduleUris.isEmpty() -> { + if (kernelUris.size == 1 && rootAvailable()) { + navigator.navigate( + InstallScreenDestination( + preselectedKernelUri = kernelUris.first().toString() + ) + ) + } + setAutoExitAfterFlash(activity) + } + + moduleUris.isNotEmpty() -> { + navigator.navigate( + FlashScreenDestination( + FlashIt.FlashModules(ArrayList(moduleUris)) + ) + ) + setAutoExitAfterFlash(activity) + } + } + } + } + + private fun setAutoExitAfterFlash(activity: Context) { + activity.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + .edit { + putBoolean("auto_exit_after_flash", true) + } + } +} + +object AppData { + object DataRefreshManager { + // 私有状态流 + private val _superuserCount = MutableStateFlow(0) + private val _moduleCount = MutableStateFlow(0) + private val _kpmModuleCount = MutableStateFlow(0) + + // 公开的只读状态流 + val superuserCount: StateFlow = _superuserCount.asStateFlow() + val moduleCount: StateFlow = _moduleCount.asStateFlow() + val kpmModuleCount: StateFlow = _kpmModuleCount.asStateFlow() + + /** + * 刷新所有数据计数 + */ + fun refreshData() { + _superuserCount.value = getSuperuserCountUse() + _moduleCount.value = getModuleCountUse() + _kpmModuleCount.value = getKpmModuleCountUse() + } + } + + /** + * 获取超级用户应用计数 + */ + fun getSuperuserCountUse(): Int { + return try { + if (!rootAvailable()) return 0 + getSuperuserCount() + } catch (_: Exception) { + 0 + } + } + + /** + * 获取模块计数 + */ + fun getModuleCountUse(): Int { + return try { + if (!rootAvailable()) return 0 + getModuleCount() + } catch (_: Exception) { + 0 + } + } + + /** + * 获取KPM模块计数 + */ + fun getKpmModuleCountUse(): Int { + return try { + if (!rootAvailable()) return 0 + val kpmVersion = getKpmVersionUse() + if (kpmVersion.isEmpty() || kpmVersion.startsWith("Error")) return 0 + getKpmModuleCount() + } catch (_: Exception) { + 0 + } + } + + /** + * 获取KPM版本 + */ + fun getKpmVersionUse(): String { + return try { + if (!rootAvailable()) return "" + val version = getKpmVersion() + version.ifEmpty { "" } + } catch (e: Exception) { + "Error: ${e.message}" + } + } + + /** + * 检查是否是完整功能模式 + */ + fun isFullFeatured(): Boolean { + val isManager = Natives.isManager + return isManager && !Natives.requireNewKernel() && rootAvailable() + } +} + +object DataRefreshUtils { + fun startDataRefreshCoroutine(scope: LifecycleCoroutineScope) { + scope.launch(Dispatchers.IO) { + while (isActive) { + AppData.DataRefreshManager.refreshData() + delay(5000) + } + } + } + + fun startSettingsMonitorCoroutine( + scope: LifecycleCoroutineScope, + activity: MainActivity, + settingsStateFlow: MutableStateFlow + ) { + scope.launch(Dispatchers.IO) { + while (isActive) { + val prefs = activity.getSharedPreferences("settings", Context.MODE_PRIVATE) + settingsStateFlow.value = MainActivity.SettingsState( + isHideOtherInfo = prefs.getBoolean("is_hide_other_info", false), + showKpmInfo = prefs.getBoolean("show_kpm_info", false) + ) + delay(1000) + } + } + } + + fun refreshData(scope: LifecycleCoroutineScope) { + scope.launch { + AppData.DataRefreshManager.refreshData() + } + } +} + +object DisplayUtils { + fun applyCustomDpi(context: Context) { + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val customDpi = prefs.getInt("app_dpi", 0) + + if (customDpi > 0) { + try { + val resources = context.resources + val metrics = resources.displayMetrics + metrics.density = customDpi / 160f + @Suppress("DEPRECATION") + metrics.scaledDensity = customDpi / 160f + metrics.densityDpi = customDpi + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/AboutCard.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/AboutCard.kt new file mode 100644 index 0000000..5dcda95 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/AboutCard.kt @@ -0,0 +1,117 @@ +package com.sukisu.ultra.ui.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.sukisu.ultra.BuildConfig +import com.sukisu.ultra.R + +@Preview +@Composable +fun AboutCard() { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + AboutCardContent() + } + } +} + +@Composable +fun AboutDialog(dismiss: () -> Unit) { + Dialog( + onDismissRequest = { dismiss() } + ) { + AboutCard() + } +} + +@Composable +private fun AboutCardContent() { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row { + Surface( + modifier = Modifier.size(40.dp), + color = colorResource(id = R.color.ic_launcher_background), + shape = CircleShape + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_monochrome), + contentDescription = "icon", + modifier = Modifier.scale(1.4f) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + + Text( + stringResource(id = R.string.app_name), + style = MaterialTheme.typography.titleSmall, + fontSize = 18.sp + ) + Text( + BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.bodySmall, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val annotatedString = AnnotatedString.fromHtml( + htmlString = stringResource( + id = R.string.about_source_code, + "GitHub", + "Telegram", + "怡子曰曰", + "明风 OuO", + "CC BY-NC-SA 4.0" + ), + linkStyles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ), + pressedStyle = SpanStyle( + color = MaterialTheme.colorScheme.primary, + background = MaterialTheme.colorScheme.secondaryContainer, + textDecoration = TextDecoration.Underline + ) + ) + ) + Text( + text = annotatedString, + style = TextStyle( + fontSize = 14.sp + ) + ) + } + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt new file mode 100644 index 0000000..10c0477 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt @@ -0,0 +1,468 @@ +package com.sukisu.ultra.ui.component + +import android.graphics.text.LineBreaker +import android.os.Build +import android.os.Parcelable +import android.text.Layout +import android.text.method.LinkMovementMethod +import android.util.Log +import android.view.ViewGroup +import android.widget.TextView +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import io.noties.markwon.Markwon +import io.noties.markwon.utils.NoCopySpannableFactory +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize +import kotlin.coroutines.resume + +private const val TAG = "DialogComponent" + +interface ConfirmDialogVisuals : Parcelable { + val title: String + val content: String + val isMarkdown: Boolean + val confirm: String? + val dismiss: String? +} + +@Parcelize +private data class ConfirmDialogVisualsImpl( + override val title: String, + override val content: String, + override val isMarkdown: Boolean, + override val confirm: String?, + override val dismiss: String?, +) : ConfirmDialogVisuals { + companion object { + val Empty: ConfirmDialogVisuals = ConfirmDialogVisualsImpl("", "", false, null, null) + } +} + +interface DialogHandle { + val isShown: Boolean + val dialogType: String + fun show() + fun hide() +} + +interface LoadingDialogHandle : DialogHandle { + suspend fun withLoading(block: suspend () -> R): R + fun showLoading() +} + +sealed interface ConfirmResult { + object Confirmed : ConfirmResult + object Canceled : ConfirmResult +} + +interface ConfirmDialogHandle : DialogHandle { + val visuals: ConfirmDialogVisuals + + fun showConfirm( + title: String, + content: String, + markdown: Boolean = false, + confirm: String? = null, + dismiss: String? = null + ) + + suspend fun awaitConfirm( + + title: String, + content: String, + markdown: Boolean = false, + confirm: String? = null, + dismiss: String? = null + ): ConfirmResult +} + +private abstract class DialogHandleBase( + val visible: MutableState, + val coroutineScope: CoroutineScope +) : DialogHandle { + override val isShown: Boolean + get() = visible.value + + override fun show() { + coroutineScope.launch { + visible.value = true + } + } + + final override fun hide() { + coroutineScope.launch { + visible.value = false + } + } + + override fun toString(): String { + return dialogType + } +} + +private class LoadingDialogHandleImpl( + visible: MutableState, + coroutineScope: CoroutineScope +) : LoadingDialogHandle, DialogHandleBase(visible, coroutineScope) { + override suspend fun withLoading(block: suspend () -> R): R { + return coroutineScope.async { + try { + visible.value = true + block() + } finally { + visible.value = false + } + }.await() + } + + override fun showLoading() { + show() + } + + override val dialogType: String get() = "LoadingDialog" +} + +typealias NullableCallback = (() -> Unit)? + +interface ConfirmCallback { + + val onConfirm: NullableCallback + + val onDismiss: NullableCallback + + val isEmpty: Boolean get() = onConfirm == null && onDismiss == null + + companion object { + operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback { + return object : ConfirmCallback { + override val onConfirm: NullableCallback + get() = onConfirmProvider() + override val onDismiss: NullableCallback + get() = onDismissProvider() + } + } + } +} + +private class ConfirmDialogHandleImpl( + visible: MutableState, + coroutineScope: CoroutineScope, + callback: ConfirmCallback, + override var visuals: ConfirmDialogVisuals = ConfirmDialogVisualsImpl.Empty, + private val resultFlow: ReceiveChannel +) : ConfirmDialogHandle, DialogHandleBase(visible, coroutineScope) { + private class ResultCollector( + private val callback: ConfirmCallback + ) : FlowCollector { + fun handleResult(result: ConfirmResult) { + Log.d(TAG, "handleResult: ${result.javaClass.simpleName}") + when (result) { + ConfirmResult.Confirmed -> onConfirm() + ConfirmResult.Canceled -> onDismiss() + } + } + + fun onConfirm() { + callback.onConfirm?.invoke() + } + + fun onDismiss() { + callback.onDismiss?.invoke() + } + + override suspend fun emit(value: ConfirmResult) { + handleResult(value) + } + } + + private val resultCollector = ResultCollector(callback) + + private var awaitContinuation: CancellableContinuation? = null + + private val isCallbackEmpty = callback.isEmpty + + init { + coroutineScope.launch { + resultFlow + .consumeAsFlow() + .onEach { result -> + awaitContinuation?.let { + awaitContinuation = null + if (it.isActive) { + it.resume(result) + } + } + } + .onEach { hide() } + .collect(resultCollector) + } + } + + private suspend fun awaitResult(): ConfirmResult { + return suspendCancellableCoroutine { + awaitContinuation = it.apply { + if (isCallbackEmpty) { + invokeOnCancellation { + visible.value = false + } + } + } + } + } + + fun updateVisuals(visuals: ConfirmDialogVisuals) { + this.visuals = visuals + } + + override fun show() { + if (visuals !== ConfirmDialogVisualsImpl.Empty) { + super.show() + } else { + throw UnsupportedOperationException("can't show confirm dialog with the Empty visuals") + } + } + + override fun showConfirm( + title: String, + content: String, + markdown: Boolean, + confirm: String?, + dismiss: String? + ) { + coroutineScope.launch { + updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss)) + show() + } + } + + override suspend fun awaitConfirm( + title: String, + content: String, + markdown: Boolean, + confirm: String?, + dismiss: String? + ): ConfirmResult { + coroutineScope.launch { + updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss)) + show() + } + return awaitResult() + } + + override val dialogType: String get() = "ConfirmDialog" + + override fun toString(): String { + return "${super.toString()}(visuals: $visuals)" + } + + companion object { + fun Saver( + visible: MutableState, + coroutineScope: CoroutineScope, + callback: ConfirmCallback, + resultChannel: ReceiveChannel + ) = Saver( + save = { + it.visuals + }, + restore = { + Log.d(TAG, "ConfirmDialog restore, visuals: $it") + ConfirmDialogHandleImpl(visible, coroutineScope, callback, it, resultChannel) + } + ) + } +} + +private class CustomDialogHandleImpl( + visible: MutableState, + coroutineScope: CoroutineScope +) : DialogHandleBase(visible, coroutineScope) { + override val dialogType: String get() = "CustomDialog" +} + +@Composable +fun rememberLoadingDialog(): LoadingDialogHandle { + val visible = remember { + mutableStateOf(false) + } + val coroutineScope = rememberCoroutineScope() + + if (visible.value) { + LoadingDialog() + } + + return remember { + LoadingDialogHandleImpl(visible, coroutineScope) + } +} + +@Composable +private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: ConfirmCallback): ConfirmDialogHandle { + val visible = rememberSaveable { + mutableStateOf(false) + } + val coroutineScope = rememberCoroutineScope() + val resultChannel = remember { + Channel() + } + + val handle = rememberSaveable( + saver = ConfirmDialogHandleImpl.Saver(visible, coroutineScope, callback, resultChannel), + init = { + ConfirmDialogHandleImpl(visible, coroutineScope, callback, visuals, resultChannel) + } + ) + + if (visible.value) { + ConfirmDialog( + handle.visuals, + confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } }, + dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } } + ) + } + + return handle +} + +@Composable +fun rememberConfirmCallback(onConfirm: NullableCallback, onDismiss: NullableCallback): ConfirmCallback { + val currentOnConfirm by rememberUpdatedState(newValue = onConfirm) + val currentOnDismiss by rememberUpdatedState(newValue = onDismiss) + return remember { + ConfirmCallback({ currentOnConfirm }, { currentOnDismiss }) + } +} + +@Composable +fun rememberConfirmDialog(onConfirm: NullableCallback = null, onDismiss: NullableCallback = null): ConfirmDialogHandle { + return rememberConfirmDialog(rememberConfirmCallback(onConfirm, onDismiss)) +} + +@Composable +fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle { + return rememberConfirmDialog(ConfirmDialogVisualsImpl.Empty, callback) +} + +@Composable +fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle { + val visible = rememberSaveable { + mutableStateOf(false) + } + val coroutineScope = rememberCoroutineScope() + if (visible.value) { + composable { visible.value = false } + } + return remember { + CustomDialogHandleImpl(visible, coroutineScope) + } +} + +@Composable +private fun LoadingDialog() { + Dialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false) + ) { + Surface( + modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) { + AlertDialog( + onDismissRequest = { + dismiss() + }, + title = { + Text(text = visuals.title) + }, + text = { + if (visuals.isMarkdown) { + MarkdownContent(content = visuals.content) + } else { + Text(text = visuals.content) + } + }, + confirmButton = { + TextButton(onClick = confirm) { + Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = dismiss) { + Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun MarkdownContent(content: String) { + val contentColor = LocalContentColor.current + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll( + state = scrollState, + flingBehavior = ScrollableDefaults.flingBehavior() + ) + .padding(12.dp) + ) { + AndroidView( + factory = { context -> + TextView(context).apply { + movementMethod = LinkMovementMethod.getInstance() + setSpannableFactory(NoCopySpannableFactory.getInstance()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE + } + hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + }, + update = { + Markwon.create(it.context).setMarkdown(it, content) + it.setTextColor(contentColor.toArgb()) + } + ) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/FabVisibilityState.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/FabVisibilityState.kt new file mode 100644 index 0000000..9042cdd --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/FabVisibilityState.kt @@ -0,0 +1,75 @@ +package com.sukisu.ultra.ui.component + +import android.annotation.SuppressLint +import androidx.compose.animation.* +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.unit.dp + +@SuppressLint("AutoboxingStateCreation") +@Composable +fun rememberFabVisibilityState(listState: LazyListState): State { + var previousScrollOffset by remember { mutableStateOf(0) } + var previousIndex by remember { mutableStateOf(0) } + val fabVisible = remember { mutableStateOf(true) } + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collect { (index, offset) -> + if (previousIndex == 0 && previousScrollOffset == 0) { + fabVisible.value = true + } else { + val isScrollingDown = when { + index > previousIndex -> false + index < previousIndex -> true + else -> offset < previousScrollOffset + } + + fabVisible.value = isScrollingDown + } + + previousIndex = index + previousScrollOffset = offset + } + } + + return fabVisible +} + +@Composable +fun AnimatedFab( + visible: Boolean, + content: @Composable () -> Unit +) { + val scale by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(targetScale = 0.8f) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .scale(scale) + .alpha(scale) + ) { + content() + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/InstallConfirmationDialog.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/InstallConfirmationDialog.kt new file mode 100644 index 0000000..6ae6a47 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/InstallConfirmationDialog.kt @@ -0,0 +1,441 @@ +package com.sukisu.ultra.ui.component + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.GetApp +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sukisu.ultra.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.util.zip.ZipInputStream + +enum class ZipType { + MODULE, + KERNEL, + UNKNOWN +} + +data class ZipFileInfo( + val uri: Uri, + val type: ZipType, + val name: String = "", + val version: String = "", + val versionCode: String = "", + val author: String = "", + val description: String = "", + val kernelVersion: String = "", + val supported: String = "" +) + +object ZipFileDetector { + + fun detectZipType(context: Context, uri: Uri): ZipType { + return try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var hasModuleProp = false + var hasToolsFolder = false + var hasAnykernelSh = false + + var entry = zipStream.nextEntry + while (entry != null) { + val entryName = entry.name.lowercase() + + when { + entryName == "module.prop" || entryName.endsWith("/module.prop") -> { + hasModuleProp = true + } + entryName.startsWith("tools/") || entryName == "tools" -> { + hasToolsFolder = true + } + entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> { + hasAnykernelSh = true + } + } + + zipStream.closeEntry() + entry = zipStream.nextEntry + } + + when { + hasModuleProp -> ZipType.MODULE + hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL + else -> ZipType.UNKNOWN + } + } + } ?: ZipType.UNKNOWN + } catch (e: IOException) { + e.printStackTrace() + ZipType.UNKNOWN + } + } + + fun parseModuleInfo(context: Context, uri: Uri): ZipFileInfo { + var zipInfo = ZipFileInfo(uri = uri, type = ZipType.MODULE) + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (entry.name.lowercase() == "module.prop" || entry.name.endsWith("/module.prop")) { + val reader = BufferedReader(InputStreamReader(zipStream)) + val props = mutableMapOf() + + var line = reader.readLine() + while (line != null) { + if (line.contains("=") && !line.startsWith("#")) { + val parts = line.split("=", limit = 2) + if (parts.size == 2) { + props[parts[0].trim()] = parts[1].trim() + } + } + line = reader.readLine() + } + + zipInfo = zipInfo.copy( + name = props["name"] ?: context.getString(R.string.unknown_module), + version = props["version"] ?: "", + versionCode = props["versionCode"] ?: "", + author = props["author"] ?: "", + description = props["description"] ?: "" + ) + break + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return zipInfo + } + + fun parseKernelInfo(context: Context, uri: Uri): ZipFileInfo { + var zipInfo = ZipFileInfo(uri = uri, type = ZipType.KERNEL) + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (entry.name.lowercase() == "anykernel.sh" || entry.name.endsWith("/anykernel.sh")) { + val reader = BufferedReader(InputStreamReader(zipStream)) + val props = mutableMapOf() + + var inPropertiesBlock = false + var line = reader.readLine() + while (line != null) { + if (line.contains("properties()")) { + inPropertiesBlock = true + } else if (inPropertiesBlock && line.contains("'; }")) { + inPropertiesBlock = false + } else if (inPropertiesBlock) { + val propertyLine = line.trim() + if (propertyLine.contains("=") && !propertyLine.startsWith("#")) { + val parts = propertyLine.split("=", limit = 2) + if (parts.size == 2) { + val key = parts[0].trim() + val value = parts[1].trim().removeSurrounding("'").removeSurrounding("\"") + when (key) { + "kernel.string" -> props["name"] = value + "supported.versions" -> props["supported"] = value + } + } + } + } + + // 解析普通变量定义 + if (line.contains("kernel.string=") && !inPropertiesBlock) { + val value = line.substringAfter("kernel.string=").trim().removeSurrounding("\"") + props["name"] = value + } + if (line.contains("supported.versions=") && !inPropertiesBlock) { + val value = line.substringAfter("supported.versions=").trim().removeSurrounding("\"") + props["supported"] = value + } + if (line.contains("kernel.version=") && !inPropertiesBlock) { + val value = line.substringAfter("kernel.version=").trim().removeSurrounding("\"") + props["version"] = value + } + if (line.contains("kernel.author=") && !inPropertiesBlock) { + val value = line.substringAfter("kernel.author=").trim().removeSurrounding("\"") + props["author"] = value + } + + line = reader.readLine() + } + + zipInfo = zipInfo.copy( + name = props["name"] ?: context.getString(R.string.unknown_kernel), + version = props["version"] ?: "", + author = props["author"] ?: "", + supported = props["supported"] ?: "", + kernelVersion = props["version"] ?: "" + ) + break + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return zipInfo + } + + suspend fun detectAndParseZipFiles(context: Context, zipUris: List): List { + return withContext(Dispatchers.IO) { + val zipFileInfos = mutableListOf() + + for (uri in zipUris) { + val zipType = detectZipType(context, uri) + val zipInfo = when (zipType) { + ZipType.MODULE -> parseModuleInfo(context, uri) + ZipType.KERNEL -> parseKernelInfo(context, uri) + ZipType.UNKNOWN -> ZipFileInfo( + uri = uri, + type = ZipType.UNKNOWN, + name = context.getString(R.string.unknown_file) + ) + } + zipFileInfos.add(zipInfo) + } + + zipFileInfos.filter { it.type != ZipType.UNKNOWN } + } + } +} + +@Composable +fun InstallConfirmationDialog( + show: Boolean, + zipFiles: List, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit +) { + if (show && zipFiles.isNotEmpty()) { + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = if (zipFiles.any { it.type == ZipType.KERNEL }) + Icons.Default.Memory else Icons.Default.Extension, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = if (zipFiles.size == 1) { + context.getString(R.string.confirm_installation) + } else { + context.getString(R.string.confirm_multiple_installation, zipFiles.size) + }, + style = MaterialTheme.typography.headlineSmall + ) + } + }, + text = { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(zipFiles.size) { index -> + val zipFile = zipFiles[index] + InstallItemCard(zipFile = zipFile) + } + } + }, + confirmButton = { + Button( + onClick = { onConfirm(zipFiles) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.GetApp, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(context.getString(R.string.install_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + context.getString(android.R.string.cancel), + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + modifier = Modifier.widthIn(min = 320.dp, max = 560.dp) + ) + } +} + +@Composable +fun InstallItemCard(zipFile: ZipFileInfo) { + val context = LocalContext.current + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = when (zipFile.type) { + ZipType.MODULE -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ZipType.KERNEL -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = when (zipFile.type) { + ZipType.MODULE -> Icons.Default.Extension + ZipType.KERNEL -> Icons.Default.Memory + else -> Icons.AutoMirrored.Filled.Help + }, + contentDescription = null, + tint = when (zipFile.type) { + ZipType.MODULE -> MaterialTheme.colorScheme.primary + ZipType.KERNEL -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = zipFile.name.ifEmpty { + when (zipFile.type) { + ZipType.MODULE -> context.getString(R.string.unknown_module) + ZipType.KERNEL -> context.getString(R.string.unknown_kernel) + else -> context.getString(R.string.unknown_file) + } + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when (zipFile.type) { + ZipType.MODULE -> context.getString(R.string.module_package) + ZipType.KERNEL -> context.getString(R.string.kernel_package) + else -> context.getString(R.string.unknown_package) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 详细信息 + if (zipFile.version.isNotEmpty() || zipFile.author.isNotEmpty() || + zipFile.description.isNotEmpty() || zipFile.supported.isNotEmpty()) { + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + thickness = 0.5.dp + ) + Spacer(modifier = Modifier.height(8.dp)) + + // 版本信息 + if (zipFile.version.isNotEmpty()) { + InfoRow( + label = context.getString(R.string.version), + value = zipFile.version + if (zipFile.versionCode.isNotEmpty()) " (${zipFile.versionCode})" else "" + ) + } + + // 作者信息 + if (zipFile.author.isNotEmpty()) { + InfoRow( + label = context.getString(R.string.author), + value = zipFile.author + ) + } + + // 描述信息 (仅模块) + if (zipFile.description.isNotEmpty() && zipFile.type == ZipType.MODULE) { + InfoRow( + label = context.getString(R.string.description), + value = zipFile.description + ) + } + + // 支持设备 (仅内核) + if (zipFile.supported.isNotEmpty() && zipFile.type == ZipType.KERNEL) { + InfoRow( + label = context.getString(R.string.supported_devices), + value = zipFile.supported + ) + } + } + } + } +} + +@Composable +fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = "$label:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 60.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/KeyEventBlocker.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/KeyEventBlocker.kt new file mode 100644 index 0000000..3c1b358 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/KeyEventBlocker.kt @@ -0,0 +1,28 @@ +package com.sukisu.ultra.ui.component + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.onKeyEvent + +@Composable +fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) { + val requester = remember { FocusRequester() } + Box( + Modifier + .onKeyEvent { + predicate(it) + } + .focusRequester(requester) + .focusable() + ) + LaunchedEffect(Unit) { + requester.requestFocus() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/KsuIsValidCheck.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/KsuIsValidCheck.kt new file mode 100644 index 0000000..eb3c5db --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/KsuIsValidCheck.kt @@ -0,0 +1,17 @@ +package com.sukisu.ultra.ui.component + +import androidx.compose.runtime.Composable +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ksuApp + +@Composable +fun KsuIsValid( + content: @Composable () -> Unit +) { + val isManager = Natives.isManager + val ksuVersion = if (isManager) Natives.version else null + + if (ksuVersion != null) { + content() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/SearchBar.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/SearchBar.kt new file mode 100644 index 0000000..03deff5 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/SearchBar.kt @@ -0,0 +1,154 @@ +package com.sukisu.ultra.ui.component + +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sukisu.ultra.ui.theme.CardConfig + +private const val TAG = "SearchBar" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchAppBar( + title: @Composable () -> Unit, + searchText: String, + onSearchTextChange: (String) -> Unit, + onClearClick: () -> Unit, + onBackClick: (() -> Unit)? = null, + onConfirm: (() -> Unit)? = null, + dropdownContent: @Composable (() -> Unit)? = null, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + var onSearch by remember { mutableStateOf(false) } + + // 获取卡片颜色和透明度 + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + val cardAlpha = CardConfig.cardAlpha + + if (onSearch) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + DisposableEffect(Unit) { + onDispose { + keyboardController?.hide() + } + } + + TopAppBar( + title = { + Box { + AnimatedVisibility( + modifier = Modifier.align(Alignment.CenterStart), + visible = !onSearch, + enter = fadeIn(), + exit = fadeOut(), + content = { title() } + ) + + AnimatedVisibility( + visible = onSearch, + enter = fadeIn(), + exit = fadeOut() + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 2.dp, end = if (onBackClick != null) 0.dp else 14.dp) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) onSearch = true + Log.d(TAG, "onFocusChanged: $focusState") + }, + value = searchText, + onValueChange = onSearchTextChange, + trailingIcon = { + IconButton( + onClick = { + onSearch = false + keyboardController?.hide() + onClearClick() + }, + content = { Icon(Icons.Filled.Close, null) } + ) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + onConfirm?.invoke() + }) + ) + } + } + }, + navigationIcon = { + if (onBackClick != null) { + IconButton( + onClick = onBackClick, + content = { Icon(Icons.AutoMirrored.Outlined.ArrowBack, null) } + ) + } + }, + actions = { + AnimatedVisibility( + visible = !onSearch + ) { + IconButton( + onClick = { onSearch = true }, + content = { Icon(Icons.Filled.Search, null) } + ) + } + + if (dropdownContent != null) { + dropdownContent() + } + + }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun SearchAppBarPreview() { + var searchText by remember { mutableStateOf("") } + SearchAppBar( + title = { Text("Search text") }, + searchText = searchText, + onSearchTextChange = { searchText = it }, + onClearClick = { searchText = "" } + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/SettingsItem.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/SettingsItem.kt new file mode 100644 index 0000000..6ccf428 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/SettingsItem.kt @@ -0,0 +1,106 @@ +package com.sukisu.ultra.ui.component + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import com.dergoogler.mmrl.ui.component.LabelItem +import com.dergoogler.mmrl.ui.component.text.TextRow +import com.sukisu.ultra.ui.theme.CardConfig + +@Composable +fun SwitchItem( + icon: ImageVector? = null, + title: String, + summary: String? = null, + checked: Boolean, + enabled: Boolean = true, + beta: Boolean = false, + onCheckedChange: (Boolean) -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val stateAlpha = remember(checked, enabled) { Modifier.alpha(if (enabled) 1f else 0.5f) } + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + ListItem( + modifier = Modifier + .toggleable( + value = checked, + interactionSource = interactionSource, + role = Role.Switch, + enabled = enabled, + indication = LocalIndication.current, + onValueChange = onCheckedChange + ), + headlineContent = { + TextRow( + leadingContent = if (beta) { + { + LabelItem( + modifier = Modifier.then(stateAlpha), + text = "Beta" + ) + } + } else null + ) { + Text( + modifier = Modifier.then(stateAlpha), + text = title, + ) + } + }, + leadingContent = icon?.let { + { + Icon( + modifier = Modifier.then(stateAlpha), + imageVector = icon, + contentDescription = title + ) + } + }, + trailingContent = { + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + interactionSource = interactionSource + ) + }, + supportingContent = { + if (summary != null) { + Text( + modifier = Modifier.then(stateAlpha), + text = summary + ) + } + } + ) + } +} + +@Composable +fun RadioItem( + title: String, + selected: Boolean, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text(title) + }, + leadingContent = { + RadioButton(selected = selected, onClick = onClick) + } + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/SuperDropdown.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/SuperDropdown.kt new file mode 100644 index 0000000..9103e55 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/SuperDropdown.kt @@ -0,0 +1,250 @@ +package com.sukisu.ultra.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SuperDropdown( + items: List, + selectedIndex: Int, + title: String, + summary: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + showValue: Boolean = true, + maxHeight: Dp? = 400.dp, + colors: SuperDropdownColors = SuperDropdownDefaults.colors(), + leftAction: (@Composable () -> Unit)? = null, + onSelectedIndexChange: (Int) -> Unit +) { + var showDialog by remember { mutableStateOf(false) } + val selectedItemText = items.getOrNull(selectedIndex) ?: "" + val itemsNotEmpty = items.isNotEmpty() + val actualEnabled = enabled && itemsNotEmpty + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = actualEnabled) { showDialog = true } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.Top + ) { + if (leftAction != null) { + leftAction() + } else if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (actualEnabled) colors.iconColor else colors.disabledIconColor, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (actualEnabled) colors.titleColor else colors.disabledTitleColor + ) + + if (summary != null) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = if (actualEnabled) colors.summaryColor else colors.disabledSummaryColor + ) + } + + if (showValue && itemsNotEmpty) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = selectedItemText, + style = MaterialTheme.typography.bodyMedium, + color = if (actualEnabled) colors.valueColor else colors.disabledValueColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = if (actualEnabled) colors.arrowColor else colors.disabledArrowColor, + modifier = Modifier.size(24.dp) + ) + } + + if (showDialog && itemsNotEmpty) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + val dialogMaxHeight = maxHeight ?: 400.dp + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(items.size) { index -> + DropdownItem( + text = items[index], + isSelected = selectedIndex == index, + colors = colors, + onClick = { + onSelectedIndexChange(index) + showDialog = false + } + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showDialog = false }) { + Text(text = stringResource(id = android.R.string.cancel)) + } + }, + containerColor = colors.dialogBackgroundColor, + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 4.dp + ) + } +} + +@Composable +private fun DropdownItem( + text: String, + isSelected: Boolean, + colors: SuperDropdownColors, + onClick: () -> Unit +) { + val backgroundColor = if (isSelected) { + colors.selectedBackgroundColor + } else { + Color.Transparent + } + + val contentColor = if (isSelected) { + colors.selectedContentColor + } else { + colors.contentColor + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = colors.selectedContentColor, + unselectedColor = colors.contentColor + ) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier.weight(1f) + ) + + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = colors.selectedContentColor, + modifier = Modifier.size(20.dp) + ) + } + } +} + +@Immutable +data class SuperDropdownColors( + val titleColor: Color, + val summaryColor: Color, + val valueColor: Color, + val iconColor: Color, + val arrowColor: Color, + val disabledTitleColor: Color, + val disabledSummaryColor: Color, + val disabledValueColor: Color, + val disabledIconColor: Color, + val disabledArrowColor: Color, + val dialogBackgroundColor: Color, + val contentColor: Color, + val selectedContentColor: Color, + val selectedBackgroundColor: Color +) + +object SuperDropdownDefaults { + @Composable + fun colors( + titleColor: Color = MaterialTheme.colorScheme.onSurface, + summaryColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + valueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + iconColor: Color = MaterialTheme.colorScheme.primary, + arrowColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTitleColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledSummaryColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + disabledValueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + disabledIconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + disabledArrowColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + dialogBackgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + selectedContentColor: Color = MaterialTheme.colorScheme.primary, + selectedBackgroundColor: Color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ): SuperDropdownColors { + return SuperDropdownColors( + titleColor = titleColor, + summaryColor = summaryColor, + valueColor = valueColor, + iconColor = iconColor, + arrowColor = arrowColor, + disabledTitleColor = disabledTitleColor, + disabledSummaryColor = disabledSummaryColor, + disabledValueColor = disabledValueColor, + disabledIconColor = disabledIconColor, + disabledArrowColor = disabledArrowColor, + dialogBackgroundColor = dialogBackgroundColor, + contentColor = contentColor, + selectedContentColor = selectedContentColor, + selectedBackgroundColor = selectedBackgroundColor + ) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt new file mode 100644 index 0000000..e37cd81 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt @@ -0,0 +1,257 @@ +package com.sukisu.ultra.ui.component + +import androidx.compose.animation.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.sukisu.ultra.R + +data class FabMenuItem( + val icon: ImageVector, + val labelRes: Int, + val color: Color = Color.Unspecified, + val onClick: () -> Unit +) + +object FabAnimationConfig { + const val ANIMATION_DURATION = 300 + const val STAGGER_DELAY = 50 + val BUTTON_SPACING = 72.dp + val BUTTON_SIZE = 56.dp + val SMALL_BUTTON_SIZE = 48.dp +} + +@Composable +fun VerticalExpandableFab( + menuItems: List, + modifier: Modifier = Modifier, + buttonSize: Dp = FabAnimationConfig.BUTTON_SIZE, + smallButtonSize: Dp = FabAnimationConfig.SMALL_BUTTON_SIZE, + buttonSpacing: Dp = FabAnimationConfig.BUTTON_SPACING, + animationDurationMs: Int = FabAnimationConfig.ANIMATION_DURATION, + staggerDelayMs: Int = FabAnimationConfig.STAGGER_DELAY, + mainButtonIcon: ImageVector = Icons.Filled.Add, + mainButtonExpandedIcon: ImageVector = Icons.Filled.Close, + onMainButtonClick: (() -> Unit)? = null, +) { + var isExpanded by remember { mutableStateOf(false) } + + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded) 45f else 0f, + animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing), + label = "mainButtonRotation" + ) + + val mainButtonScale by animateFloatAsState( + targetValue = if (isExpanded) 1.1f else 1f, + animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing), + label = "mainButtonScale" + ) + + Box( + modifier = modifier.wrapContentSize(), + contentAlignment = Alignment.BottomEnd + ) { + menuItems.forEachIndexed { index, menuItem -> + val animatedOffsetY by animateFloatAsState( + targetValue = if (isExpanded) -(buttonSpacing.value * (index + 1)) else 0f, + animationSpec = tween( + durationMillis = animationDurationMs, + delayMillis = if (isExpanded) { + index * staggerDelayMs + } else { + (menuItems.size - index - 1) * staggerDelayMs + }, + easing = FastOutSlowInEasing + ), + label = "fabOffset$index" + ) + + val animatedScale by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, + animationSpec = tween( + durationMillis = animationDurationMs, + delayMillis = if (isExpanded) { + index * staggerDelayMs + 100 + } else { + (menuItems.size - index - 1) * staggerDelayMs + }, + easing = FastOutSlowInEasing + ), + label = "fabScale$index" + ) + + val animatedAlpha by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, + animationSpec = tween( + durationMillis = animationDurationMs, + delayMillis = if (isExpanded) { + index * staggerDelayMs + 150 + } else { + (menuItems.size - index - 1) * staggerDelayMs + }, + easing = FastOutSlowInEasing + ), + label = "fabAlpha$index" + ) + + Row( + modifier = Modifier + .offset(y = animatedOffsetY.dp) + .scale(animatedScale) + .alpha(animatedAlpha), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + AnimatedVisibility( + visible = isExpanded && animatedScale > 0.5f, + enter = slideInHorizontally( + initialOffsetX = { it / 2 }, + animationSpec = tween(200) + ) + fadeIn(animationSpec = tween(200)), + exit = slideOutHorizontally( + targetOffsetX = { it / 2 }, + animationSpec = tween(150) + ) + fadeOut(animationSpec = tween(150)) + ) { + Surface( + modifier = Modifier.padding(end = 16.dp), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.inverseSurface, + tonalElevation = 6.dp + ) { + Text( + text = stringResource(menuItem.labelRes), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.inverseOnSurface + ) + } + } + + SmallFloatingActionButton( + onClick = { + menuItem.onClick() + isExpanded = false + }, + modifier = Modifier.size(smallButtonSize), + containerColor = if (menuItem.color != Color.Unspecified) { + menuItem.color + } else { + MaterialTheme.colorScheme.secondary + }, + contentColor = if (menuItem.color != Color.Unspecified) { + if (menuItem.color == Color.Gray) Color.White + else MaterialTheme.colorScheme.onSecondary + } else { + MaterialTheme.colorScheme.onSecondary + }, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 4.dp, + pressedElevation = 6.dp + ) + ) { + Icon( + imageVector = menuItem.icon, + contentDescription = stringResource(menuItem.labelRes), + modifier = Modifier.size(20.dp) + ) + } + } + } + + FloatingActionButton( + onClick = { + onMainButtonClick?.invoke() + isExpanded = !isExpanded + }, + modifier = Modifier.size(buttonSize).scale(mainButtonScale), + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 6.dp, + pressedElevation = 8.dp, + hoveredElevation = 8.dp + ) + ) { + Icon( + imageVector = if (isExpanded) mainButtonExpandedIcon else mainButtonIcon, + contentDescription = stringResource( + if (isExpanded) R.string.collapse_menu else R.string.expand_menu + ), + modifier = Modifier + .size(24.dp) + .rotate(if (mainButtonIcon == Icons.Filled.Add) rotationAngle else 0f) + ) + } + } +} + +object FabMenuPresets { + fun getScrollMenuItems( + onScrollToTop: () -> Unit, + onScrollToBottom: () -> Unit + ) = listOf( + FabMenuItem( + icon = Icons.Filled.KeyboardArrowDown, + labelRes = R.string.scroll_to_bottom, + onClick = onScrollToBottom + ), + FabMenuItem( + icon = Icons.Filled.KeyboardArrowUp, + labelRes = R.string.scroll_to_top, + onClick = onScrollToTop + ) + ) + + @Composable + fun getBatchActionMenuItems( + onCancel: () -> Unit, + onDeny: () -> Unit, + onAllow: () -> Unit, + onUnmountModules: () -> Unit, + onDisableUnmount: () -> Unit + ) = listOf( + FabMenuItem( + icon = Icons.Filled.Close, + labelRes = R.string.cancel, + color = Color.Gray, + onClick = onCancel + ), + FabMenuItem( + icon = Icons.Filled.Block, + labelRes = R.string.deny_authorization, + color = MaterialTheme.colorScheme.error, + onClick = onDeny + ), + FabMenuItem( + icon = Icons.Filled.Check, + labelRes = R.string.grant_authorization, + color = MaterialTheme.colorScheme.primary, + onClick = onAllow + ), + FabMenuItem( + icon = Icons.Filled.FolderOff, + labelRes = R.string.unmount_modules, + onClick = onUnmountModules + ), + FabMenuItem( + icon = Icons.Filled.Folder, + labelRes = R.string.disable_unmount, + onClick = onDisableUnmount + ) + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/AppProfileConfig.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/AppProfileConfig.kt new file mode 100644 index 0000000..5ba9695 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/AppProfileConfig.kt @@ -0,0 +1,58 @@ +package com.sukisu.ultra.ui.component.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.SwitchItem + +@Composable +fun AppProfileConfig( + modifier: Modifier = Modifier, + fixedName: Boolean, + enabled: Boolean, + profile: Natives.Profile, + onProfileChange: (Natives.Profile) -> Unit, +) { + Column(modifier = modifier) { + if (!fixedName) { + OutlinedTextField( + label = { Text(stringResource(R.string.profile_name)) }, + value = profile.name, + onValueChange = { onProfileChange(profile.copy(name = it)) } + ) + } + SwitchItem( + title = stringResource(R.string.profile_umount_modules), + summary = stringResource(R.string.profile_umount_modules_summary), + checked = if (enabled) { + profile.umountModules + } else { + Natives.isDefaultUmountModules() + }, + enabled = enabled, + onCheckedChange = { + onProfileChange( + profile.copy( + umountModules = it, + nonRootUseDefault = false + ) + ) + } + ) + } +} + +@Preview +@Composable +private fun AppProfileConfigPreview() { + var profile by remember { mutableStateOf(Natives.Profile("")) } + AppProfileConfig(fixedName = true, enabled = false, profile = profile) { + profile = it + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/RootProfileConfig.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/RootProfileConfig.kt new file mode 100644 index 0000000..7593a49 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/RootProfileConfig.kt @@ -0,0 +1,481 @@ +package com.sukisu.ultra.ui.component.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.input.InputDialog +import com.maxkeppeler.sheets.input.models.* +import com.maxkeppeler.sheets.list.ListDialog +import com.maxkeppeler.sheets.list.models.ListOption +import com.maxkeppeler.sheets.list.models.ListSelection +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.profile.Capabilities +import com.sukisu.ultra.profile.Groups +import com.sukisu.ultra.ui.component.rememberCustomDialog +import com.sukisu.ultra.ui.util.isSepolicyValid + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RootProfileConfig( + modifier: Modifier = Modifier, + fixedName: Boolean, + profile: Natives.Profile, + onProfileChange: (Natives.Profile) -> Unit, +) { + Column(modifier = modifier) { + if (!fixedName) { + OutlinedTextField( + label = { Text(stringResource(R.string.profile_name)) }, + value = profile.name, + onValueChange = { onProfileChange(profile.copy(name = it)) } + ) + } + + /* + var expanded by remember { mutableStateOf(false) } + val currentNamespace = when (profile.namespace) { + Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited) + Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global) + Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual) + else -> stringResource(R.string.profile_namespace_inherited) + } + ListItem(headlineContent = { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.profile_namespace)) }, + value = currentNamespace, + onValueChange = {}, + trailingIcon = { + if (expanded) Icon(Icons.Filled.ArrowDropUp, null) + else Icon(Icons.Filled.ArrowDropDown, null) + }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_namespace_inherited)) }, + onClick = { + onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal)) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_namespace_global)) }, + onClick = { + onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal)) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_namespace_individual)) }, + onClick = { + onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal)) + expanded = false + }, + ) + } + } + }) + */ + + UidPanel(uid = profile.uid, label = "uid", onUidChange = { + onProfileChange( + profile.copy( + uid = it, + rootUseDefault = false + ) + ) + }) + + UidPanel(uid = profile.gid, label = "gid", onUidChange = { + onProfileChange( + profile.copy( + gid = it, + rootUseDefault = false + ) + ) + }) + + val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e -> + e.mapNotNull { g -> + Groups.entries.find { it.gid == g } + } + } + GroupsPanel(selectedGroups) { + onProfileChange( + profile.copy( + groups = it.map { group -> group.gid }.ifEmpty { listOf(0) }, + rootUseDefault = false + ) + ) + } + + val selectedCaps = profile.capabilities.mapNotNull { e -> + Capabilities.entries.find { it.cap == e } + } + + CapsPanel(selectedCaps) { + onProfileChange( + profile.copy( + capabilities = it.map { cap -> cap.cap }, + rootUseDefault = false + ) + ) + } + + SELinuxPanel(profile = profile, onSELinuxChange = { domain, rules -> + onProfileChange( + profile.copy( + context = domain, + rules = rules, + rootUseDefault = false + ) + ) + }) + + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun GroupsPanel(selected: List, closeSelection: (selection: Set) -> Unit) { + val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit -> + val groups = Groups.entries.toTypedArray().sortedWith( + compareBy { if (selected.contains(it)) 0 else 1 } + .then(compareBy { + when (it) { + Groups.ROOT -> 0 + Groups.SYSTEM -> 1 + Groups.SHELL -> 2 + else -> Int.MAX_VALUE + } + }) + .then(compareBy { it.name }) + + ) + val options = groups.map { value -> + ListOption( + titleText = value.display, + subtitleText = value.desc, + selected = selected.contains(value), + ) + } + + val selection = HashSet(selected) + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + ListDialog( + state = rememberUseCaseState(visible = true, onFinishedRequest = { + closeSelection(selection) + }, onCloseRequest = { + dismiss() + }), + header = Header.Default( + title = stringResource(R.string.profile_groups), + ), + selection = ListSelection.Multiple( + showCheckBoxes = true, + options = options, + maxChoices = 32, // Kernel only supports 32 groups at most + ) { indecies, _ -> + // Handle selection + selection.clear() + indecies.forEach { index -> + val group = groups[index] + selection.add(group) + } + } + ) + } + } + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + + Column( + modifier = Modifier + .fillMaxSize() + .clickable { + selectGroupsDialog.show() + } + .padding(16.dp) + ) { + Text(stringResource(R.string.profile_groups)) + FlowRow { + selected.forEach { group -> + AssistChip( + modifier = Modifier.padding(3.dp), + onClick = { /*TODO*/ }, + label = { Text(group.display) }) + } + } + } + + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun CapsPanel( + selected: Collection, + closeSelection: (selection: Set) -> Unit +) { + val selectCapabilitiesDialog = rememberCustomDialog { dismiss -> + val caps = Capabilities.entries.toTypedArray().sortedWith( + compareBy { if (selected.contains(it)) 0 else 1 } + .then(compareBy { it.name }) + ) + val options = caps.map { value -> + ListOption( + titleText = value.display, + subtitleText = value.desc, + selected = selected.contains(value), + ) + } + + val selection = HashSet(selected) + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + ListDialog( + state = rememberUseCaseState(visible = true, onFinishedRequest = { + closeSelection(selection) + }, onCloseRequest = { + dismiss() + }), + header = Header.Default( + title = stringResource(R.string.profile_capabilities), + ), + selection = ListSelection.Multiple( + showCheckBoxes = true, + options = options + ) { indecies, _ -> + // Handle selection + selection.clear() + indecies.forEach { index -> + val group = caps[index] + selection.add(group) + } + } + ) + } + } + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + + Column( + modifier = Modifier + .fillMaxSize() + .clickable { + selectCapabilitiesDialog.show() + } + .padding(16.dp) + ) { + Text(stringResource(R.string.profile_capabilities)) + FlowRow { + selected.forEach { group -> + AssistChip( + modifier = Modifier.padding(3.dp), + onClick = { /*TODO*/ }, + label = { Text(group.display) }) + } + } + } + + } +} + +@Composable +private fun UidPanel(uid: Int, label: String, onUidChange: (Int) -> Unit) { + + ListItem(headlineContent = { + var isError by remember { + mutableStateOf(false) + } + var lastValidUid by remember { + mutableIntStateOf(uid) + } + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(label) }, + value = uid.toString(), + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + }), + onValueChange = { + if (it.isEmpty()) { + onUidChange(0) + return@OutlinedTextField + } + val valid = isTextValidUid(it) + + val targetUid = if (valid) it.toInt() else lastValidUid + if (valid) { + lastValidUid = it.toInt() + } + + onUidChange(targetUid) + + isError = !valid + } + ) + }) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SELinuxPanel( + profile: Natives.Profile, + onSELinuxChange: (domain: String, rules: String) -> Unit +) { + val editSELinuxDialog = rememberCustomDialog { dismiss -> + var domain by remember { mutableStateOf(profile.context) } + var rules by remember { mutableStateOf(profile.rules) } + + val inputOptions = listOf( + InputTextField( + text = domain, + header = InputHeader( + title = stringResource(id = R.string.profile_selinux_domain), + ), + type = InputTextFieldType.OUTLINED, + required = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Next + ), + resultListener = { + domain = it ?: "" + }, + validationListener = { value -> + // value can be a-zA-Z0-9_ + val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$") + if (value?.matches(regex) == true) ValidationResult.Valid + else ValidationResult.Invalid("Domain must be in the format of \"user:role:type:level\"") + } + ), + InputTextField( + text = rules, + header = InputHeader( + title = stringResource(id = R.string.profile_selinux_rules), + ), + type = InputTextFieldType.OUTLINED, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + ), + singleLine = false, + resultListener = { + rules = it ?: "" + }, + validationListener = { value -> + if (isSepolicyValid(value)) ValidationResult.Valid + else ValidationResult.Invalid("SELinux rules is invalid!") + } + ) + ) + + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + InputDialog( + state = rememberUseCaseState( + visible = true, + onFinishedRequest = { + onSELinuxChange(domain, rules) + }, + onCloseRequest = { + dismiss() + }), + header = Header.Default( + title = stringResource(R.string.profile_selinux_context), + ), + selection = InputSelection( + input = inputOptions, + onPositiveClick = { result -> + // Handle selection + }, + ) + ) + } + } + + ListItem(headlineContent = { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .clickable { + editSELinuxDialog.show() + }, + enabled = false, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + label = { Text(text = stringResource(R.string.profile_selinux_context)) }, + value = profile.context, + onValueChange = { } + ) + }) +} + +@Preview +@Composable +private fun RootProfileConfigPreview() { + var profile by remember { mutableStateOf(Natives.Profile("")) } + RootProfileConfig(fixedName = true, profile = profile) { + profile = it + } +} + +private fun isTextValidUid(text: String): Boolean { + return text.isNotEmpty() && text.isDigitsOnly() && text.toInt() >= 0 +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/TemplateConfig.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/TemplateConfig.kt new file mode 100644 index 0000000..7af311b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/profile/TemplateConfig.kt @@ -0,0 +1,105 @@ +package com.sukisu.ultra.ui.component.profile + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ReadMore +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.Create +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.util.listAppProfileTemplates +import com.sukisu.ultra.ui.util.setSepolicy +import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById + +/** + * @author weishu + * @date 2023/10/21. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TemplateConfig( + profile: Natives.Profile, + onViewTemplate: (id: String) -> Unit = {}, + onManageTemplate: () -> Unit = {}, + onProfileChange: (Natives.Profile) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var template by rememberSaveable { + mutableStateOf(profile.rootTemplate ?: "") + } + val profileTemplates = listAppProfileTemplates() + val noTemplates = profileTemplates.isEmpty() + + ListItem(headlineContent = { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.profile_template)) }, + value = template.ifEmpty { "None" }, + onValueChange = {}, + trailingIcon = { + if (noTemplates) { + IconButton( + onClick = onManageTemplate + ) { + Icon(Icons.Filled.Create, null) + } + } else if (expanded) Icon(Icons.Filled.ArrowDropUp, null) + else Icon(Icons.Filled.ArrowDropDown, null) + }, + ) + if (profileTemplates.isEmpty()) { + return@ExposedDropdownMenuBox + } + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + profileTemplates.forEach { tid -> + val templateInfo = + getTemplateInfoById(tid) ?: return@forEach + DropdownMenuItem( + text = { Text(tid) }, + onClick = { + template = tid + if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) { + onProfileChange( + profile.copy( + rootTemplate = tid, + rootUseDefault = false, + uid = templateInfo.uid, + gid = templateInfo.gid, + groups = templateInfo.groups, + capabilities = templateInfo.capabilities, + context = templateInfo.context, + namespace = templateInfo.namespace, + ) + ) + } + expanded = false + }, + trailingIcon = { + IconButton(onClick = { + onViewTemplate(tid) + }) { + Icon(Icons.AutoMirrored.Filled.ReadMore, null) + } + } + ) + } + } + } + }) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/AppProfile.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/AppProfile.kt new file mode 100644 index 0000000..7764cb0 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/AppProfile.kt @@ -0,0 +1,586 @@ +package com.sukisu.ultra.ui.screen + +import android.annotation.SuppressLint +import androidx.annotation.StringRes +import androidx.compose.animation.* +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination +import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.SwitchItem +import com.sukisu.ultra.ui.component.profile.AppProfileConfig +import com.sukisu.ultra.ui.component.profile.RootProfileConfig +import com.sukisu.ultra.ui.component.profile.TemplateConfig +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById +import kotlinx.coroutines.launch + +/** + * @author weishu + * @date 2023/5/16. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun AppProfileScreen( + navigator: DestinationsNavigator, + appInfo: SuperUserViewModel.AppInfo, +) { + val context = LocalContext.current + val snackBarHost = LocalSnackbarHost.current + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scope = rememberCoroutineScope() + val failToUpdateAppProfile = stringResource(R.string.failed_to_update_app_profile).format(appInfo.label) + val failToUpdateSepolicy = stringResource(R.string.failed_to_update_sepolicy).format(appInfo.label) + val suNotAllowed = stringResource(R.string.su_not_allowed).format(appInfo.label) + + val packageName = appInfo.packageName + val initialProfile = Natives.getAppProfile(packageName, appInfo.uid) + if (initialProfile.allowSu) { + initialProfile.rules = getSepolicy(packageName) + } + var profile by rememberSaveable { + mutableStateOf(initialProfile) + } + + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + val cardAlpha = CardConfig.cardAlpha + + Scaffold( + topBar = { + TopBar( + title = appInfo.label, + packageName = packageName, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + onBack = dropUnlessResumed { navigator.popBackStack() }, + scrollBehavior = scrollBehavior + ) + }, + snackbarHost = { SnackbarHost(hostState = snackBarHost) }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { paddingValues -> + AppProfileInner( + modifier = Modifier + .padding(paddingValues) + .nestedScroll(scrollBehavior.nestedScrollConnection) + .verticalScroll(rememberScrollState()), + packageName = appInfo.packageName, + appLabel = appInfo.label, + appIcon = { + AsyncImage( + model = ImageRequest.Builder(context).data(appInfo.packageInfo).crossfade(true).build(), + contentDescription = appInfo.label, + modifier = Modifier + .padding(4.dp) + .width(48.dp) + .height(48.dp) + ) + }, + profile = profile, + onViewTemplate = { + getTemplateInfoById(it)?.let { info -> + navigator.navigate(TemplateEditorScreenDestination(info)) + } + }, + onManageTemplate = { + navigator.navigate(AppProfileTemplateScreenDestination()) + }, + onProfileChange = { + scope.launch { + if (it.allowSu) { + // sync with allowlist.c - forbid_system_uid + if (appInfo.uid < 2000 && appInfo.uid != 1000) { + snackBarHost.showSnackbar(suNotAllowed) + return@launch + } + if (!it.rootUseDefault && it.rules.isNotEmpty() && !setSepolicy(profile.name, it.rules)) { + snackBarHost.showSnackbar(failToUpdateSepolicy) + return@launch + } + } + if (!Natives.setAppProfile(it)) { + snackBarHost.showSnackbar(failToUpdateAppProfile.format(appInfo.uid)) + } else { + profile = it + } + } + }, + ) + } +} + +@Composable +private fun AppProfileInner( + modifier: Modifier = Modifier, + packageName: String, + appLabel: String, + appIcon: @Composable () -> Unit, + profile: Natives.Profile, + onViewTemplate: (id: String) -> Unit = {}, + onManageTemplate: () -> Unit = {}, + onProfileChange: (Natives.Profile) -> Unit, +) { + val isRootGranted = profile.allowSu + val cardColors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh) + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + Column(modifier = modifier) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + colors = cardColors, + elevation = getCardElevation(), + ) { + AppMenuBox(packageName) { + ListItem( + headlineContent = { + Text( + text = appLabel, + style = MaterialTheme.typography.titleMedium + ) + }, + supportingContent = { + Text( + text = packageName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + leadingContent = appIcon, + ) + } + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + colors = cardColors, + elevation = getCardElevation(), + ) { + SwitchItem( + icon = Icons.Filled.Security, + title = stringResource(id = R.string.superuser), + checked = isRootGranted, + onCheckedChange = { onProfileChange(profile.copy(allowSu = it)) }, + ) + } + + Crossfade( + targetState = isRootGranted, + label = "RootAccess" + ) { current -> + Column( + modifier = Modifier.padding(bottom = 6.dp + 48.dp + 6.dp /* SnackBar height */) + ) { + if (current) { + val initialMode = if (profile.rootUseDefault) { + Mode.Default + } else if (profile.rootTemplate != null) { + Mode.Template + } else { + Mode.Custom + } + var mode by rememberSaveable { + mutableStateOf(initialMode) + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + colors = cardColors, + elevation = getCardElevation(), + ) { + ProfileBox(mode, true) { + // template mode shouldn't change profile here! + if (it == Mode.Default || it == Mode.Custom) { + onProfileChange( + profile.copy( + rootUseDefault = it == Mode.Default, + rootTemplate = null + ) + ) + } + mode = it + } + } + + AnimatedVisibility( + visible = mode != Mode.Default, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + colors = cardColors, + elevation = getCardElevation(), + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Crossfade( + targetState = mode, + label = "ProfileMode" + ) { currentMode -> + when (currentMode) { + Mode.Template -> { + TemplateConfig( + profile = profile, + onViewTemplate = onViewTemplate, + onManageTemplate = onManageTemplate, + onProfileChange = onProfileChange + ) + } + + Mode.Custom -> { + RootProfileConfig( + fixedName = true, + profile = profile, + onProfileChange = onProfileChange + ) + } + + else -> {} + } + } + } + } + } + } else { + val mode = if (profile.nonRootUseDefault) Mode.Default else Mode.Custom + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + colors = cardColors, + elevation = getCardElevation(), + ) { + ProfileBox(mode, false) { + onProfileChange(profile.copy(nonRootUseDefault = (it == Mode.Default))) + } + } + + AnimatedVisibility( + visible = mode == Mode.Custom, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + colors = cardColors, + elevation = getCardElevation(), + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + AppProfileConfig( + fixedName = true, + profile = profile, + enabled = mode == Mode.Custom, + onProfileChange = onProfileChange + ) + } + } + } + } + } + } + } + } +} + +private enum class Mode(@param:StringRes private val res: Int) { + Default(R.string.profile_default), Template(R.string.profile_template), Custom(R.string.profile_custom); + + val text: String + @Composable get() = stringResource(res) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + title: String, + packageName: String, + onBack: () -> Unit, + colors: TopAppBarColors, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + TopAppBar( + title = { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = packageName, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(0.8f) + ) + } + }, + colors = colors, + navigationIcon = { + IconButton( + onClick = onBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + windowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ), + scrollBehavior = scrollBehavior, + modifier = Modifier.shadow( + elevation = if ((scrollBehavior?.state?.overlappedFraction ?: 0f) > 0.01f) + 4.dp else 0.dp, + ) + ) +} + +@Composable +private fun ProfileBox( + mode: Mode, + hasTemplate: Boolean, + onModeChange: (Mode) -> Unit, +) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.profile), + style = MaterialTheme.typography.titleMedium + ) + }, + supportingContent = { + Text( + text = mode.text, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = null, + ) + }, + ) + + HorizontalDivider( + thickness = Dp.Hairline, + ) + + ListItem( + headlineContent = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + FilterChip( + selected = mode == Mode.Default, + onClick = { onModeChange(Mode.Default) }, + label = { + Text( + text = stringResource(R.string.profile_default), + style = MaterialTheme.typography.bodyMedium + ) + }, + shape = MaterialTheme.shapes.small + ) + + if (hasTemplate) { + FilterChip( + selected = mode == Mode.Template, + onClick = { onModeChange(Mode.Template) }, + label = { + Text( + text = stringResource(R.string.profile_template), + style = MaterialTheme.typography.bodyMedium + ) + }, + shape = MaterialTheme.shapes.small + ) + } + + FilterChip( + selected = mode == Mode.Custom, + onClick = { onModeChange(Mode.Custom) }, + label = { + Text( + text = stringResource(R.string.profile_custom), + style = MaterialTheme.typography.bodyMedium + ) + }, + shape = MaterialTheme.shapes.small + ) + } + } + ) + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +private fun AppMenuBox( + packageName: String, + content: @Composable () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var touchPoint: Offset by remember { mutableStateOf(Offset.Zero) } + val density = LocalDensity.current + + BoxWithConstraints( + Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + touchPoint = it + expanded = true + } + ) + } + ) { + content() + + val (offsetX, offsetY) = with(density) { + (touchPoint.x.toDp()) to (-touchPoint.y.toDp()) + } + + DropdownMenu( + expanded = expanded, + offset = DpOffset(offsetX, offsetY), + onDismissRequest = { + expanded = false + } + ) { + AppMenuOption( + text = stringResource(id = R.string.launch_app), + onClick = { + expanded = false + launchApp(packageName) + } + ) + + AppMenuOption( + text = stringResource(id = R.string.force_stop_app), + onClick = { + expanded = false + forceStopApp(packageName) + } + ) + + AppMenuOption( + text = stringResource(id = R.string.restart_app), + onClick = { + expanded = false + restartApp(packageName) + } + ) + } + } +} + +@Composable +private fun AppMenuOption(text: String, onClick: () -> Unit) { + DropdownMenuItem( + text = { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + }, + onClick = onClick + ) +} + +@Preview +@Composable +private fun AppProfilePreview() { + var profile by remember { mutableStateOf(Natives.Profile("")) } + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + Surface { + AppProfileInner( + packageName = "icu.nullptr.test", + appLabel = "Test", + appIcon = { + Icon( + imageVector = Icons.Filled.Android, + contentDescription = null, + ) + }, + profile = profile, + onProfileChange = { + profile = it + }, + ) + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/BottomBarDestination.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/BottomBarDestination.kt new file mode 100644 index 0000000..4175ecf --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/BottomBarDestination.kt @@ -0,0 +1,24 @@ +package com.sukisu.ultra.ui.screen + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.ui.graphics.vector.ImageVector +import com.ramcosta.composedestinations.generated.destinations.* +import com.ramcosta.composedestinations.spec.DirectionDestinationSpec +import com.sukisu.ultra.R + +enum class BottomBarDestination( + val direction: DirectionDestinationSpec, + @param:StringRes val label: Int, + val iconSelected: ImageVector, + val iconNotSelected: ImageVector, + val rootRequired: Boolean, +) { + Home(HomeScreenDestination, R.string.home, Icons.Filled.Home, Icons.Outlined.Home, false), + Kpm(KpmScreenDestination, R.string.kpm_title, Icons.Filled.Archive, Icons.Outlined.Archive, true), + SuperUser(SuperUserScreenDestination, R.string.superuser, Icons.Filled.AdminPanelSettings, Icons.Outlined.AdminPanelSettings, true), + Module(ModuleScreenDestination, R.string.module, Icons.Filled.Extension, Icons.Outlined.Extension, true), + Settings(SettingScreenDestination, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false), +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/ExecuteModuleAction.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/ExecuteModuleAction.kt new file mode 100644 index 0000000..26359a9 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/ExecuteModuleAction.kt @@ -0,0 +1,147 @@ +package com.sukisu.ultra.ui.screen + +import android.os.Environment +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.KeyEventBlocker +import com.sukisu.ultra.ui.util.LocalSnackbarHost +import com.sukisu.ultra.ui.util.runModuleAction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +@Composable +@Destination +fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) { + var text by rememberSaveable { mutableStateOf("") } + var tempText : String + val logContent = rememberSaveable { StringBuilder() } + val snackBarHost = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + var isActionRunning by rememberSaveable { mutableStateOf(true) } + + BackHandler(enabled = isActionRunning) { + // Disable back button if action is running + } + + LaunchedEffect(Unit) { + if (text.isNotEmpty()) { + return@LaunchedEffect + } + withContext(Dispatchers.IO) { + runModuleAction( + moduleId = moduleId, + onStdout = { + tempText = "$it\n" + if (tempText.startsWith("")) { // clear command + text = tempText.substring(6) + } else { + text += tempText + } + logContent.append(it).append("\n") + }, + onStderr = { + logContent.append(it).append("\n") + } + ) + } + isActionRunning = false + } + + Scaffold( + topBar = { + TopBar( + isActionRunning = isActionRunning, + onSave = { + if (!isActionRunning) { + scope.launch { + val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) + val date = format.format(Date()) + val file = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "KernelSU_module_action_log_${date}.log" + ) + file.writeText(logContent.toString()) + snackBarHost.showSnackbar("Log saved to ${file.absolutePath}") + } + } + } + ) + }, + floatingActionButton = { + if (!isActionRunning) { + ExtendedFloatingActionButton( + text = { Text(text = stringResource(R.string.close)) }, + icon = { Icon(Icons.Filled.Close, contentDescription = null) }, + onClick = { + navigator.popBackStack() + } + ) + } + }, + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { SnackbarHost(snackBarHost) } + ) { innerPadding -> + KeyEventBlocker { + it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + Column( + modifier = Modifier + .fillMaxSize(1f) + .padding(innerPadding) + .verticalScroll(scrollState), + ) { + LaunchedEffect(text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Text( + modifier = Modifier.padding(8.dp), + text = text, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + fontFamily = FontFamily.Monospace, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar(isActionRunning: Boolean, onSave: () -> Unit = {}) { + TopAppBar( + title = { Text(stringResource(R.string.action)) }, + actions = { + IconButton( + onClick = onSave, + enabled = !isActionRunning + ) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(id = R.string.save_log), + ) + } + } + ) +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt new file mode 100644 index 0000000..e991839 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt @@ -0,0 +1,768 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.activity.ComponentActivity +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.KeyEventBlocker +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.util.* +import com.sukisu.ultra.ui.viewmodel.ModuleViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import androidx.core.content.edit +import com.sukisu.ultra.ui.util.module.ModuleOperationUtils +import com.sukisu.ultra.ui.util.module.ModuleUtils + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +enum class FlashingStatus { + FLASHING, + SUCCESS, + FAILED +} + +private var currentFlashingStatus = mutableStateOf(FlashingStatus.FLASHING) + +// 添加模块安装状态跟踪 +data class ModuleInstallStatus( + val totalModules: Int = 0, + val currentModule: Int = 0, + val currentModuleName: String = "", + val failedModules: MutableList = mutableListOf(), + val verifiedModules: MutableList = mutableListOf() // 添加已验证模块列表 +) + +private var moduleInstallStatus = mutableStateOf(ModuleInstallStatus()) + +// 存储模块URI和验证状态的映射 +private var moduleVerificationMap = mutableMapOf() + +fun setFlashingStatus(status: FlashingStatus) { + currentFlashingStatus.value = status +} + +fun updateModuleInstallStatus( + totalModules: Int? = null, + currentModule: Int? = null, + currentModuleName: String? = null, + failedModule: String? = null, + verifiedModule: String? = null +) { + val current = moduleInstallStatus.value + moduleInstallStatus.value = current.copy( + totalModules = totalModules ?: current.totalModules, + currentModule = currentModule ?: current.currentModule, + currentModuleName = currentModuleName ?: current.currentModuleName + ) + + if (failedModule != null) { + val updatedFailedModules = current.failedModules.toMutableList() + updatedFailedModules.add(failedModule) + moduleInstallStatus.value = moduleInstallStatus.value.copy( + failedModules = updatedFailedModules + ) + } + + if (verifiedModule != null) { + val updatedVerifiedModules = current.verifiedModules.toMutableList() + updatedVerifiedModules.add(verifiedModule) + moduleInstallStatus.value = moduleInstallStatus.value.copy( + verifiedModules = updatedVerifiedModules + ) + } +} + +fun setModuleVerificationStatus(uri: Uri, isVerified: Boolean) { + moduleVerificationMap[uri] = isVerified +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Destination +fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) { + val context = LocalContext.current + + val shouldAutoExit = remember { + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.getBoolean("auto_exit_after_flash", false) + } + + // 是否通过从外部启动的模块安装 + val isExternalInstall = remember { + when (flashIt) { + is FlashIt.FlashModule -> { + (context as? ComponentActivity)?.intent?.let { intent -> + intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND + } ?: false + } + is FlashIt.FlashModules -> { + (context as? ComponentActivity)?.intent?.let { intent -> + intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND + } ?: false + } + else -> false + } + } + + var text by rememberSaveable { mutableStateOf("") } + var tempText: String + val logContent = rememberSaveable { StringBuilder() } + var showFloatAction by rememberSaveable { mutableStateOf(false) } + // 添加状态跟踪是否已经完成刷写 + var hasFlashCompleted by rememberSaveable { mutableStateOf(false) } + var hasExecuted by rememberSaveable { mutableStateOf(false) } + // 更新模块状态管理 + var hasUpdateExecuted by rememberSaveable { mutableStateOf(false) } + var hasUpdateCompleted by rememberSaveable { mutableStateOf(false) } + + val snackBarHost = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val viewModel: ModuleViewModel = viewModel() + + val errorCodeString = stringResource(R.string.error_code) + val checkLogString = stringResource(R.string.check_log) + val logSavedString = stringResource(R.string.log_saved) + val installingModuleString = stringResource(R.string.installing_module) + + // 当前模块安装状态 + val currentStatus = moduleInstallStatus.value + + // 重置状态 + LaunchedEffect(flashIt) { + when (flashIt) { + is FlashIt.FlashModules -> { + if (flashIt.currentIndex == 0) { + moduleInstallStatus.value = ModuleInstallStatus( + totalModules = flashIt.uris.size, + currentModule = 1 + ) + hasFlashCompleted = false + hasExecuted = false + moduleVerificationMap.clear() + } + } + is FlashIt.FlashModuleUpdate -> { + hasUpdateCompleted = false + hasUpdateExecuted = false + } + else -> { + hasFlashCompleted = false + hasExecuted = false + } + } + } + + // 处理更新模块安装 + LaunchedEffect(flashIt) { + if (flashIt !is FlashIt.FlashModuleUpdate) return@LaunchedEffect + if (hasUpdateExecuted || hasUpdateCompleted || text.isNotEmpty()) { + return@LaunchedEffect + } + + hasUpdateExecuted = true + + withContext(Dispatchers.IO) { + setFlashingStatus(FlashingStatus.FLASHING) + + try { + logContent.append(text).append("\n") + } catch (_: Exception) { + logContent.append(text).append("\n") + } + + flashModuleUpdate(flashIt.uri, onFinish = { showReboot, code -> + if (code != 0) { + text += "$errorCodeString $code.\n$checkLogString\n" + setFlashingStatus(FlashingStatus.FAILED) + } else { + setFlashingStatus(FlashingStatus.SUCCESS) + + // 处理模块更新成功后的验证标志 + val isVerified = moduleVerificationMap[flashIt.uri] ?: false + ModuleOperationUtils.handleModuleUpdate(context, flashIt.uri, isVerified) + + viewModel.markNeedRefresh() + } + if (showReboot) { + text += "\n\n\n" + showFloatAction = true + + // 如果是内部安装,显示重启按钮后不自动返回 + if (isExternalInstall) { + return@flashModuleUpdate + } + } + hasUpdateCompleted = true + + // 如果是外部安装或需要自动退出的模块更新且不需要重启,延迟后自动返回 + if (isExternalInstall || shouldAutoExit) { + scope.launch { + kotlinx.coroutines.delay(1000) + if (shouldAutoExit) { + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.edit { remove("auto_exit_after_flash") } + } + (context as? ComponentActivity)?.finish() + } + } + }, onStdout = { + tempText = "$it\n" + if (tempText.startsWith("")) { // clear command + text = tempText.substring(6) + } else { + text += tempText + } + logContent.append(it).append("\n") + }, onStderr = { + logContent.append(it).append("\n") + }) + } + } + + // 安装但排除更新模块 + LaunchedEffect(flashIt) { + if (flashIt is FlashIt.FlashModuleUpdate) return@LaunchedEffect + if (hasExecuted || hasFlashCompleted || text.isNotEmpty()) { + return@LaunchedEffect + } + + hasExecuted = true + + withContext(Dispatchers.IO) { + setFlashingStatus(FlashingStatus.FLASHING) + + if (flashIt is FlashIt.FlashModules) { + try { + val currentUri = flashIt.uris[flashIt.currentIndex] + val moduleName = getModuleNameFromUri(context, currentUri) + updateModuleInstallStatus( + currentModuleName = moduleName + ) + text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, moduleName) + logContent.append(text).append("\n") + } catch (_: Exception) { + text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, "Module") + logContent.append(text).append("\n") + } + } + + flashIt(flashIt, onFinish = { showReboot, code -> + if (code != 0) { + text += "$errorCodeString $code.\n$checkLogString\n" + setFlashingStatus(FlashingStatus.FAILED) + + if (flashIt is FlashIt.FlashModules) { + updateModuleInstallStatus( + failedModule = moduleInstallStatus.value.currentModuleName + ) + } + } else { + setFlashingStatus(FlashingStatus.SUCCESS) + + // 处理模块安装成功后的验证标志 + when (flashIt) { + is FlashIt.FlashModule -> { + val isVerified = moduleVerificationMap[flashIt.uri] ?: false + ModuleOperationUtils.handleModuleInstallSuccess(context, flashIt.uri, isVerified) + if (isVerified) { + updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName) + } + } + is FlashIt.FlashModules -> { + val currentUri = flashIt.uris[flashIt.currentIndex] + val isVerified = moduleVerificationMap[currentUri] ?: false + ModuleOperationUtils.handleModuleInstallSuccess(context, currentUri, isVerified) + if (isVerified) { + updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName) + } + } + + else -> {} + } + + viewModel.markNeedRefresh() + } + if (showReboot) { + text += "\n\n\n" + showFloatAction = true + } + + hasFlashCompleted = true + + if (flashIt is FlashIt.FlashModules && flashIt.currentIndex < flashIt.uris.size - 1) { + val nextFlashIt = flashIt.copy( + currentIndex = flashIt.currentIndex + 1 + ) + scope.launch { + kotlinx.coroutines.delay(500) + navigator.navigate(FlashScreenDestination(nextFlashIt)) + } + } else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModules && flashIt.currentIndex >= flashIt.uris.size - 1) { + // 如果是外部安装或需要自动退出且是最后一个模块,安装完成后自动返回 + scope.launch { + kotlinx.coroutines.delay(1000) + if (shouldAutoExit) { + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.edit { remove("auto_exit_after_flash") } + } + (context as? ComponentActivity)?.finish() + } + } else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModule) { + // 如果是外部安装或需要自动退出的单个模块,安装完成后自动返回 + scope.launch { + kotlinx.coroutines.delay(1000) + if (shouldAutoExit) { + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.edit { remove("auto_exit_after_flash") } + } + (context as? ComponentActivity)?.finish() + } + } + }, onStdout = { + tempText = "$it\n" + if (tempText.startsWith("")) { // clear command + text = tempText.substring(6) + } else { + text += tempText + } + logContent.append(it).append("\n") + }, onStderr = { + logContent.append(it).append("\n") + }) + } + } + + val onBack: () -> Unit = { + val canGoBack = when (flashIt) { + is FlashIt.FlashModuleUpdate -> currentFlashingStatus.value != FlashingStatus.FLASHING + else -> currentFlashingStatus.value != FlashingStatus.FLASHING + } + + if (canGoBack) { + if (isExternalInstall) { + (context as? ComponentActivity)?.finish() + } else { + if (flashIt is FlashIt.FlashModules || flashIt is FlashIt.FlashModuleUpdate) { + viewModel.markNeedRefresh() + viewModel.fetchModuleList() + navigator.navigate(ModuleScreenDestination) + } else { + viewModel.markNeedRefresh() + viewModel.fetchModuleList() + navigator.popBackStack() + } + } + } + } + + BackHandler(enabled = true) { + onBack() + } + + Scaffold( + topBar = { + TopBar( + currentFlashingStatus.value, + currentStatus, + onBack = onBack, + onSave = { + scope.launch { + val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) + val date = format.format(Date()) + val file = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "KernelSU_install_log_${date}.log" + ) + file.writeText(logContent.toString()) + snackBarHost.showSnackbar(logSavedString.format(file.absolutePath)) + } + }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + if (showFloatAction) { + ExtendedFloatingActionButton( + onClick = { + scope.launch { + withContext(Dispatchers.IO) { + reboot() + } + } + }, + icon = { + Icon( + Icons.Filled.Refresh, + contentDescription = stringResource(id = R.string.reboot) + ) + }, + text = { + Text(text = stringResource(id = R.string.reboot)) + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + expanded = true + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackBarHost) }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + KeyEventBlocker { + it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + + Column( + modifier = Modifier + .fillMaxSize(1f) + .padding(innerPadding) + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + if (flashIt is FlashIt.FlashModules) { + ModuleInstallProgressBar( + currentIndex = flashIt.currentIndex + 1, + totalCount = flashIt.uris.size, + currentModuleName = currentStatus.currentModuleName, + status = currentFlashingStatus.value, + failedModules = currentStatus.failedModules + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + LaunchedEffect(text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Text( + modifier = Modifier.padding(16.dp), + text = text, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +// 显示模块安装进度条和状态 +@Composable +fun ModuleInstallProgressBar( + currentIndex: Int, + totalCount: Int, + currentModuleName: String, + status: FlashingStatus, + failedModules: List +) { + val progressColor = when(status) { + FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary + FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary + FlashingStatus.FAILED -> MaterialTheme.colorScheme.error + } + + val progress = animateFloatAsState( + targetValue = currentIndex.toFloat() / totalCount.toFloat(), + label = "InstallProgress" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // 模块名称和进度 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = currentModuleName.ifEmpty { stringResource(R.string.module) }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "$currentIndex/$totalCount", + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 进度条 + LinearProgressIndicator( + progress = { progress.value }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = progressColor, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 失败模块列表 + AnimatedVisibility( + visible = failedModules.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = stringResource(R.string.module_failed_count, failedModules.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 失败模块列表 + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.small + ) + .padding(8.dp) + ) { + failedModules.forEach { moduleName -> + Text( + text = "• $moduleName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + status: FlashingStatus, + moduleStatus: ModuleInstallStatus = ModuleInstallStatus(), + onBack: () -> Unit, + onSave: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + val cardAlpha = CardConfig.cardAlpha + + val statusColor = when(status) { + FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary + FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary + FlashingStatus.FAILED -> MaterialTheme.colorScheme.error + } + + TopAppBar( + title = { + Column { + Text( + text = stringResource( + when (status) { + FlashingStatus.FLASHING -> R.string.flashing + FlashingStatus.SUCCESS -> R.string.flash_success + FlashingStatus.FAILED -> R.string.flash_failed + } + ), + style = MaterialTheme.typography.titleLarge, + color = statusColor + ) + + if (moduleStatus.failedModules.isNotEmpty()) { + Text( + text = stringResource(R.string.module_failed_count, moduleStatus.failedModules.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + actions = { + IconButton(onClick = onSave) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(id = R.string.save_log), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) +} + +suspend fun getModuleNameFromUri(context: Context, uri: Uri): String { + return withContext(Dispatchers.IO) { + try { + if (uri == Uri.EMPTY) { + return@withContext context.getString(R.string.unknown_module) + } + if (!ModuleUtils.isUriAccessible(context, uri)) { + return@withContext context.getString(R.string.unknown_module) + } + ModuleUtils.extractModuleName(context, uri) + } catch (_: Exception) { + context.getString(R.string.unknown_module) + } + } +} + +@Parcelize +sealed class FlashIt : Parcelable { + data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) : FlashIt() + data class FlashModule(val uri: Uri) : FlashIt() + data class FlashModules(val uris: List, val currentIndex: Int = 0) : FlashIt() + data class FlashModuleUpdate(val uri: Uri) : FlashIt() // 模块更新 + data object FlashRestore : FlashIt() + data object FlashUninstall : FlashIt() +} + +// 模块更新刷写 +fun flashModuleUpdate( + uri: Uri, + onFinish: (Boolean, Int) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +) { + flashModule(uri, onFinish, onStdout, onStderr) +} + +fun flashIt( + flashIt: FlashIt, + onFinish: (Boolean, Int) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +) { + when (flashIt) { + is FlashIt.FlashBoot -> installBoot( + flashIt.boot, + flashIt.lkm, + flashIt.ota, + flashIt.partition, + onFinish, + onStdout, + onStderr + ) + is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr) + is FlashIt.FlashModules -> { + if (flashIt.uris.isEmpty() || flashIt.currentIndex >= flashIt.uris.size) { + onFinish(false, 0) + return + } + + val currentUri = flashIt.uris[flashIt.currentIndex] + onStdout("\n") + + flashModule(currentUri, onFinish, onStdout, onStderr) + } + is FlashIt.FlashModuleUpdate -> { + onFinish(false, 0) + } + FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr) + FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr) + } +} + +@Preview +@Composable +fun FlashScreenPreview() { + FlashScreen(EmptyDestinationsNavigator, FlashIt.FlashUninstall) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt new file mode 100644 index 0000000..b6ef712 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt @@ -0,0 +1,925 @@ +package com.sukisu.ultra.ui.screen + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.PowerManager +import android.system.Os +import androidx.annotation.StringRes +import androidx.compose.animation.* +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.TaskAlt +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.pm.PackageInfoCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.InstallScreenDestination +import com.ramcosta.composedestinations.generated.destinations.SuSFSConfigScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.KernelVersion +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.KsuIsValid +import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha +import com.sukisu.ultra.ui.theme.CardConfig.cardElevation +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.susfs.util.SuSFSManager +import com.sukisu.ultra.ui.util.checkNewVersion +import com.sukisu.ultra.ui.util.getSuSFSVersion +import com.sukisu.ultra.ui.util.module.LatestVersionInfo +import com.sukisu.ultra.ui.util.reboot +import com.sukisu.ultra.ui.viewmodel.HomeViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.random.Random + +/** + * @author ShirkNeko + * @date 2025/9/29. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Destination(start = true) +@Composable +fun HomeScreen(navigator: DestinationsNavigator) { + val context = LocalContext.current + val viewModel = viewModel() + val coroutineScope = rememberCoroutineScope() + + val pullRefreshState = rememberPullRefreshState( + refreshing = viewModel.isRefreshing, + onRefresh = { + viewModel.onPullRefresh(context) + } + ) + + LaunchedEffect(key1 = navigator) { + viewModel.loadUserSettings(context) + coroutineScope.launch { + viewModel.loadCoreData() + delay(100) + viewModel.loadExtendedData(context) + } + + // 启动数据变化监听 + coroutineScope.launch { + while (true) { + delay(5000) // 每5秒检查一次 + viewModel.autoRefreshIfNeeded(context) + } + } + } + + // 监听数据刷新状态流 + LaunchedEffect(viewModel.dataRefreshTrigger) { + viewModel.dataRefreshTrigger.collect { _ -> + // 数据刷新时的额外处理可以在这里添加 + } + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val scrollState = rememberScrollState() + + Scaffold( + topBar = { + TopBar( + scrollBehavior = scrollBehavior, + navigator = navigator, + isDataLoaded = viewModel.isCoreDataLoaded + ) + }, + contentWindowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ) + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(top = 12.dp, start = 16.dp, end = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 状态卡片 + if (viewModel.isCoreDataLoaded) { + StatusCard( + systemStatus = viewModel.systemStatus, + onClickInstall = { + navigator.navigate(InstallScreenDestination(preselectedKernelUri = null)) + } + ) + + // 警告信息 + if (viewModel.systemStatus.requireNewKernel) { + WarningCard( + stringResource(id = R.string.require_kernel_version).format( + Natives.getSimpleVersionFull(), + Natives.MINIMAL_SUPPORTED_KERNEL_FULL + ) + ) + } + + if (viewModel.systemStatus.ksuVersion != null && !viewModel.systemStatus.isRootAvailable) { + WarningCard( + stringResource(id = R.string.grant_root_failed) + ) + } + + // 只有在没有其他警告信息时才显示不兼容内核警告 + val shouldShowWarnings = viewModel.systemStatus.requireNewKernel || + (viewModel.systemStatus.ksuVersion != null && !viewModel.systemStatus.isRootAvailable) + + if (Natives.version <= Natives.MINIMAL_NEW_IOCTL_KERNEL && !shouldShowWarnings && viewModel.systemStatus.ksuVersion != null) { + IncompatibleKernelCard() + Spacer(Modifier.height(12.dp)) + } + } + + // 更新检查 + if (viewModel.isExtendedDataLoaded) { + val checkUpdate = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + .getBoolean("check_update", true) + if (checkUpdate) { + UpdateCard() + } + + // 信息卡片 + InfoCard( + systemInfo = viewModel.systemInfo, + isSimpleMode = viewModel.isSimpleMode, + isHideSusfsStatus = viewModel.isHideSusfsStatus, + isHideZygiskImplement = viewModel.isHideZygiskImplement, + showKpmInfo = viewModel.showKpmInfo, + lkmMode = viewModel.systemStatus.lkmMode, + ) + + // 链接卡片 + if (!viewModel.isSimpleMode && !viewModel.isHideLinkCard) { + ContributionCard() + DonateCard() + LearnMoreCard() + } + } + + if (!viewModel.isExtendedDataLoaded) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@Composable +fun UpdateCard() { + val context = LocalContext.current + val latestVersionInfo = LatestVersionInfo() + val newVersion by produceState(initialValue = latestVersionInfo) { + value = withContext(Dispatchers.IO) { + checkNewVersion() + } + } + + val currentVersionCode = getManagerVersion(context).second + val newVersionCode = newVersion.versionCode + val newVersionUrl = newVersion.downloadUrl + val changelog = newVersion.changelog + + val uriHandler = LocalUriHandler.current + val title = stringResource(id = R.string.module_changelog) + val updateText = stringResource(id = R.string.module_update) + + AnimatedVisibility( + visible = newVersionCode > currentVersionCode, + enter = fadeIn() + expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ), + exit = shrinkVertically() + fadeOut() + ) { + val updateDialog = rememberConfirmDialog(onConfirm = { uriHandler.openUri(newVersionUrl) }) + WarningCard( + message = stringResource(id = R.string.new_version_available).format(newVersionCode), + color = MaterialTheme.colorScheme.outlineVariant, + onClick = { + if (changelog.isEmpty()) { + uriHandler.openUri(newVersionUrl) + } else { + updateDialog.showConfirm( + title = title, + content = changelog, + markdown = true, + confirm = updateText + ) + } + } + ) + } +} + +@Composable +fun RebootDropdownItem(@StringRes id: Int, reason: String = "") { + DropdownMenuItem( + text = { Text(stringResource(id)) }, + onClick = { reboot(reason) }) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + scrollBehavior: TopAppBarScrollBehavior? = null, + navigator: DestinationsNavigator, + isDataLoaded: Boolean = false +) { + val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + + TopAppBar( + title = { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + actions = { + if (isDataLoaded) { + // SuSFS 配置按钮 + val susfsVersion = getSuSFSVersion() + if (susfsVersion.isNotEmpty() && !susfsVersion.startsWith("[-]") && SuSFSManager.isBinaryAvailable(context)) { + IconButton(onClick = { + navigator.navigate(SuSFSConfigScreenDestination) + }) { + Icon( + imageVector = Icons.Filled.Tune, + contentDescription = stringResource(R.string.susfs_config_setting_title) + ) + } + } + + // 重启按钮 + var showDropdown by remember { mutableStateOf(false) } + KsuIsValid { + IconButton(onClick = { + showDropdown = true + }) { + Icon( + imageVector = Icons.Filled.PowerSettingsNew, + contentDescription = stringResource(id = R.string.reboot) + ) + + DropdownMenu(expanded = showDropdown, onDismissRequest = { + showDropdown = false + }) { + RebootDropdownItem(id = R.string.reboot) + + val pm = + LocalContext.current.getSystemService(Context.POWER_SERVICE) as PowerManager? + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && pm?.isRebootingUserspaceSupported == true) { + RebootDropdownItem(id = R.string.reboot_userspace, reason = "userspace") + } + RebootDropdownItem(id = R.string.reboot_recovery, reason = "recovery") + RebootDropdownItem(id = R.string.reboot_bootloader, reason = "bootloader") + RebootDropdownItem(id = R.string.reboot_download, reason = "download") + RebootDropdownItem(id = R.string.reboot_edl, reason = "edl") + } + } + } + } + }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) +} + +@Composable +private fun StatusCard( + systemStatus: HomeViewModel.SystemStatus, + onClickInstall: () -> Unit = {} +) { + ElevatedCard( + colors = getCardColors( + if (systemStatus.ksuVersion != null) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.errorContainer + ), + elevation = getCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (systemStatus.isRootAvailable || systemStatus.kernelVersion.isGKI()) { + onClickInstall() + } + } + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when { + systemStatus.ksuVersion != null -> { + + val workingModeText = when { + Natives.isSafeMode -> stringResource(id = R.string.safe_mode) + else -> stringResource(id = R.string.home_working) + } + + val workingModeSurfaceText = when { + systemStatus.lkmMode == true -> "LKM" + else -> "Built-in" + } + + Icon( + Icons.Outlined.TaskAlt, + contentDescription = stringResource(R.string.home_working), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(28.dp) + .padding( + horizontal = 4.dp + ), + ) + + Column(Modifier.padding(start = 20.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = workingModeText, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(Modifier.width(8.dp)) + + // 工作模式标签 + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + ) { + Text( + text = workingModeSurfaceText, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } + + Spacer(Modifier.width(6.dp)) + + // 架构标签 + if (Os.uname().machine != "aarch64") { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + ) { + Text( + text = Os.uname().machine, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding( + horizontal = 6.dp, + vertical = 2.dp + ), + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + + val isHideVersion = LocalContext.current.getSharedPreferences( + "settings", + Context.MODE_PRIVATE + ) + .getBoolean("is_hide_version", false) + + if (!isHideVersion) { + Spacer(Modifier.height(4.dp)) + systemStatus.ksuFullVersion?.let { + Text( + text = stringResource(R.string.home_working_version, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } + } + + systemStatus.kernelVersion.isGKI() -> { + Icon( + Icons.Outlined.Warning, + contentDescription = stringResource(R.string.home_not_installed), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(28.dp) + .padding( + horizontal = 4.dp + ), + ) + + Column(Modifier.padding(start = 20.dp)) { + Text( + text = stringResource(R.string.home_not_installed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.home_click_to_install), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + else -> { + Icon( + Icons.Outlined.Block, + contentDescription = stringResource(R.string.home_unsupported), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(28.dp) + .padding( + horizontal = 4.dp + ), + ) + + Column(Modifier.padding(start = 20.dp)) { + Text( + text = stringResource(R.string.home_unsupported), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.home_unsupported_reason), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + } +} + +@Composable +fun WarningCard( + message: String, + color: Color = MaterialTheme.colorScheme.error, + onClick: (() -> Unit)? = null +) { + ElevatedCard( + colors = getCardColors(color), + elevation = getCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(onClick?.let { Modifier.clickable { it() } } ?: Modifier) + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +fun ContributionCard() { + val uriHandler = LocalUriHandler.current + val links = listOf("https://github.com/ShirkNeko", "https://github.com/udochina") + + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer), + elevation = getCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val randomIndex = Random.nextInt(links.size) + uriHandler.openUri(links[randomIndex]) + } + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = stringResource(R.string.home_ContributionCard_kernelsu), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.home_click_to_ContributionCard_kernelsu), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +fun LearnMoreCard() { + val uriHandler = LocalUriHandler.current + val url = stringResource(R.string.home_learn_kernelsu_url) + + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = cardElevation) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + uriHandler.openUri(url) + } + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = stringResource(R.string.home_learn_kernelsu), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.home_click_to_learn_kernelsu), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +fun DonateCard() { + val uriHandler = LocalUriHandler.current + + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer), + elevation = getCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + uriHandler.openUri("https://patreon.com/weishu") + } + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = stringResource(R.string.home_support_title), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.home_support_content), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun InfoCard( + systemInfo: HomeViewModel.SystemInfo, + isSimpleMode: Boolean, + isHideSusfsStatus: Boolean, + isHideZygiskImplement: Boolean, + showKpmInfo: Boolean, + lkmMode: Boolean? +) { + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer), + elevation = getCardElevation(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 16.dp), + ) { + @Composable + fun InfoCardItem( + label: String, + content: String, + icon: ImageVector? = null, + ) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier + .size(28.dp) + .padding(vertical = 4.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + softWrap = true + ) + } + } + } + + InfoCardItem( + stringResource(R.string.home_kernel), + systemInfo.kernelRelease, + icon = Icons.Default.Memory, + ) + + if (!isSimpleMode) { + InfoCardItem( + stringResource(R.string.home_android_version), + systemInfo.androidVersion, + icon = Icons.Default.Android, + ) + } + + InfoCardItem( + stringResource(R.string.home_device_model), + systemInfo.deviceModel, + icon = Icons.Default.PhoneAndroid, + ) + + InfoCardItem( + stringResource(R.string.home_manager_version), + "${systemInfo.managerVersion.first} (${systemInfo.managerVersion.second.toInt()})", + icon = Icons.Default.SettingsSuggest, + ) + + if (!isSimpleMode && + (systemInfo.suSFSStatus != "Supported")) { + InfoCardItem( + stringResource(R.string.home_hook_type), + Natives.getHookType(), + icon = Icons.Default.Link + ) + } + + // 活跃管理器 + if (!isSimpleMode && systemInfo.isDynamicSignEnabled && systemInfo.managersList != null) { + val signatureMap = systemInfo.managersList.managers.groupBy { it.signatureIndex } + + val managersText = buildString { + signatureMap.toSortedMap().forEach { (signatureIndex, managers) -> + append(managers.joinToString(", ") { "UID: ${it.uid}" }) + append(" ") + append( + when (signatureIndex) { + 0 -> "(${stringResource(R.string.default_signature)})" + 100 -> "(${stringResource(R.string.dynamic_managerature)})" + else -> if (signatureIndex >= 1) "(${ + stringResource( + R.string.signature_index, + signatureIndex + ) + })" else "(${stringResource(R.string.unknown_signature)})" + } + ) + append(" | ") + } + }.trimEnd(' ', '|') + + InfoCardItem( + stringResource(R.string.multi_manager_list), + managersText.ifEmpty { stringResource(R.string.no_active_manager) }, + icon = Icons.Default.Group, + ) + } + + InfoCardItem( + stringResource(R.string.home_selinux_status), + systemInfo.seLinuxStatus, + icon = Icons.Default.Security, + ) + + if (!isHideZygiskImplement && !isSimpleMode && systemInfo.zygiskImplement != "None") { + InfoCardItem( + stringResource(R.string.home_zygisk_implement), + systemInfo.zygiskImplement, + icon = Icons.Default.Adb, + ) + } + + if (!isSimpleMode) { + if (lkmMode != true && !showKpmInfo) { + val displayVersion = + if (systemInfo.kpmVersion.isEmpty() || systemInfo.kpmVersion.startsWith("Error")) { + val statusText = if (Natives.isKPMEnabled()) { + stringResource(R.string.kernel_patched) + } else { + stringResource(R.string.kernel_not_enabled) + } + "${stringResource(R.string.not_supported)} ($statusText)" + } else { + "${stringResource(R.string.supported)} (${systemInfo.kpmVersion})" + } + + InfoCardItem( + stringResource(R.string.home_kpm_version), + displayVersion, + icon = Icons.Default.Archive + ) + } + } + + if (!isSimpleMode && !isHideSusfsStatus && + systemInfo.suSFSStatus == "Supported" && + systemInfo.suSFSVersion.isNotEmpty() + ) { + + val infoText = SuSFSInfoText(systemInfo) + + InfoCardItem( + stringResource(R.string.home_susfs_version), + infoText, + icon = Icons.Default.Storage + ) + } + } + } +} + +@SuppressLint("ComposableNaming") +@Composable +private fun SuSFSInfoText(systemInfo: HomeViewModel.SystemInfo): String = buildString { + append(systemInfo.suSFSVersion) + + when { + Natives.getHookType() == "Manual" -> { + append(" (${stringResource(R.string.manual_hook)})") + } + + Natives.getHookType() == "Inline" -> { + append(" (${stringResource(R.string.inline_hook)})") + } + + else -> { + append(" (${Natives.getHookType()})") + } + } +} + +fun getManagerVersion(context: Context): Pair { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)!! + val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) + return Pair(packageInfo.versionName!!, versionCode) +} + +@Preview +@Composable +private fun StatusCardPreview() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatusCard( + HomeViewModel.SystemStatus( + isManager = true, + ksuVersion = 1, + lkmMode = null, + kernelVersion = KernelVersion(5, 10, 101), + isRootAvailable = true + ) + ) + + StatusCard( + HomeViewModel.SystemStatus( + isManager = true, + ksuVersion = 40000, + lkmMode = true, + kernelVersion = KernelVersion(5, 10, 101), + isRootAvailable = true + ) + ) + + StatusCard( + HomeViewModel.SystemStatus( + isManager = false, + ksuVersion = null, + lkmMode = true, + kernelVersion = KernelVersion(5, 10, 101), + isRootAvailable = false + ) + ) + + StatusCard( + HomeViewModel.SystemStatus( + isManager = false, + ksuVersion = null, + lkmMode = false, + kernelVersion = KernelVersion(4, 10, 101), + isRootAvailable = false + ) + ) + } +} + +@Composable +private fun IncompatibleKernelCard() { + val currentKver = remember { Natives.version } + val threshold = Natives.MINIMAL_NEW_IOCTL_KERNEL + + val msg = stringResource( + id = R.string.incompatible_kernel_msg, + currentKver, + threshold + ) + + WarningCard( + message = msg, + color = MaterialTheme.colorScheme.error + ) +} + +@Preview +@Composable +private fun WarningCardPreview() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + WarningCard(message = "Warning message") + WarningCard( + message = "Warning message ", + MaterialTheme.colorScheme.outlineVariant, + onClick = {}) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt new file mode 100644 index 0000000..5b04b89 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt @@ -0,0 +1,1102 @@ +package com.sukisu.ultra.ui.screen + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.animation.* +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Input +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.list.ListDialog +import com.maxkeppeler.sheets.list.models.ListOption +import com.maxkeppeler.sheets.list.models.ListSelection +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.getKernelVersion +import com.sukisu.ultra.ui.component.DialogHandle +import com.sukisu.ultra.ui.component.SuperDropdown +import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.component.rememberCustomDialog +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha +import com.sukisu.ultra.ui.theme.CardConfig.cardElevation +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import zako.zako.zako.zakoui.screen.kernelFlash.component.SlotSelectionDialog + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ + +enum class KpmPatchOption { + FOLLOW_KERNEL, + PATCH_KPM, + UNDO_PATCH_KPM +} + +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun InstallScreen( + navigator: DestinationsNavigator, + preselectedKernelUri: String? = null +) { + val context = LocalContext.current + var installMethod by remember { mutableStateOf(null) } + var lkmSelection by remember { mutableStateOf(LkmSelection.KmiNone) } + var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) } + var showRebootDialog by remember { mutableStateOf(false) } + var showSlotSelectionDialog by remember { mutableStateOf(false) } + var showKpmPatchDialog by remember { mutableStateOf(false) } + var tempKernelUri by remember { mutableStateOf(null) } + + val kernelVersion = getKernelVersion() + val isGKI = kernelVersion.isGKI() + val isAbDevice = produceState(initialValue = false) { + value = isAbDevice() + }.value + val summary = stringResource(R.string.horizon_kernel_summary) + + // 处理预选的内核文件 + LaunchedEffect(preselectedKernelUri) { + preselectedKernelUri?.let { uriString -> + try { + val preselectedUri = uriString.toUri() + val horizonMethod = InstallMethod.HorizonKernel( + uri = preselectedUri, + summary = summary + ) + installMethod = horizonMethod + tempKernelUri = preselectedUri + + if (isAbDevice) { + showSlotSelectionDialog = true + } else { + showKpmPatchDialog = true + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + if (showRebootDialog) { + RebootDialog( + show = true, + onDismiss = { showRebootDialog = false }, + onConfirm = { + showRebootDialog = false + try { + val process = Runtime.getRuntime().exec("su") + process.outputStream.bufferedWriter().use { writer -> + writer.write("svc power reboot\n") + writer.write("exit\n") + } + } catch (_: Exception) { + Toast.makeText(context, R.string.failed_reboot, Toast.LENGTH_SHORT).show() + } + } + ) + } + + var partitionSelectionIndex by remember { mutableIntStateOf(0) } + var partitionsState by remember { mutableStateOf>(emptyList()) } + var hasCustomSelected by remember { mutableStateOf(false) } + + val onInstall = { + installMethod?.let { method -> + when (method) { + is InstallMethod.HorizonKernel -> { + method.uri?.let { uri -> + navigator.navigate( + KernelFlashScreenDestination( + kernelUri = uri, + selectedSlot = method.slot, + kpmPatchEnabled = kpmPatchOption == KpmPatchOption.PATCH_KPM, + kpmUndoPatch = kpmPatchOption == KpmPatchOption.UNDO_PATCH_KPM + ) + ) + } + } + else -> { + val isOta = method is InstallMethod.DirectInstallToInactiveSlot + val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex) + val flashIt = FlashIt.FlashBoot( + boot = if (method is InstallMethod.SelectFile) method.uri else null, + lkm = lkmSelection, + ota = isOta, + partition = partitionSelection + ) + navigator.navigate(FlashScreenDestination(flashIt)) + } + } + } + Unit + } + + // 槽位选择 + SlotSelectionDialog( + show = showSlotSelectionDialog && isAbDevice, + onDismiss = { showSlotSelectionDialog = false }, + onSlotSelected = { slot -> + showSlotSelectionDialog = false + val horizonMethod = InstallMethod.HorizonKernel( + uri = tempKernelUri, + slot = slot, + summary = summary + ) + installMethod = horizonMethod + + if (preselectedKernelUri != null) { + showKpmPatchDialog = true + } + } + ) + + KpmPatchSelectionDialog( + show = showKpmPatchDialog, + currentOption = kpmPatchOption, + onDismiss = { showKpmPatchDialog = false }, + onOptionSelected = { option -> + kpmPatchOption = option + showKpmPatchDialog = false + } + ) + + val currentKmi by produceState(initialValue = "") { + value = getCurrentKmi() + } + + val selectKmiDialog = rememberSelectKmiDialog { kmi -> + kmi?.let { + lkmSelection = LkmSelection.KmiString(it) + onInstall() + } + } + + val onClickNext = { + if (isGKI && lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank() && installMethod !is InstallMethod.HorizonKernel) { + selectKmiDialog.show() + } else { + onInstall() + } + } + + val selectLkmLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> + val isKo = isKoFile(context, uri) + if (isKo) { + lkmSelection = LkmSelection.LkmUri(uri) + } else { + lkmSelection = LkmSelection.KmiNone + Toast.makeText( + context, + context.getString(R.string.install_only_support_ko_file), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + val onLkmUpload = { + selectLkmLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/octet-stream" + }) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + topBar = { + TopBar( + onBack = { navigator.popBackStack() }, + scrollBehavior = scrollBehavior + ) + }, + contentWindowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ) + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .nestedScroll(scrollBehavior.nestedScrollConnection) + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp) + ) { + SelectInstallMethod( + isGKI = isGKI, + onSelected = { method -> + if (method is InstallMethod.HorizonKernel && method.uri != null) { + if (isAbDevice) { + tempKernelUri = method.uri + showSlotSelectionDialog = true + } else { + installMethod = method + showKpmPatchDialog = true + } + } else { + installMethod = method + } + }, + kpmPatchOption = kpmPatchOption, + onKpmPatchOptionChanged = { kpmPatchOption = it }, + selectedMethod = installMethod + ) + + // 选择LKM直接安装分区 + AnimatedVisibility( + visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot, + enter = fadeIn() + expandVertically(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant), + elevation = getCardElevation(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) { + val isOta = installMethod is InstallMethod.DirectInstallToInactiveSlot + val suffix = produceState(initialValue = "", isOta) { + value = getSlotSuffix(isOta) + }.value + + val partitions = produceState(initialValue = emptyList()) { + value = getAvailablePartitions() + }.value + + val defaultPartition = produceState(initialValue = "") { + value = getDefaultPartition() + }.value + + partitionsState = partitions + val displayPartitions = partitions.map { name -> + if (defaultPartition == name) "$name (default)" else name + } + + val defaultIndex = partitions.indexOf(defaultPartition).takeIf { it >= 0 } ?: 0 + if (!hasCustomSelected) partitionSelectionIndex = defaultIndex + + SuperDropdown( + items = displayPartitions, + selectedIndex = partitionSelectionIndex, + title = "${stringResource(R.string.install_select_partition)} (${suffix})", + onSelectedIndexChange = { index -> + hasCustomSelected = true + partitionSelectionIndex = index + }, + leftAction = { + Icon( + Icons.Default.Edit, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(end = 16.dp), + contentDescription = null + ) + } + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + if (isGKI) { + // 使用本地的LKM文件 + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant), + elevation = getCardElevation(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) { + ListItem( + headlineContent = { + Text(stringResource(id = R.string.install_upload_lkm_file)) + }, + supportingContent = { + (lkmSelection as? LkmSelection.LkmUri)?.let { + Text( + stringResource( + id = R.string.selected_lkm, + it.uri.lastPathSegment ?: "(file)" + ) + ) + } + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Filled.Input, + contentDescription = null + ) + }, + modifier = Modifier + .fillMaxWidth() + .clickable { onLkmUpload() } + ) + } + } + + (installMethod as? InstallMethod.HorizonKernel)?.let { method -> + if (method.slot != null) { + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant), + elevation = getCardElevation(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + Text( + text = stringResource( + id = R.string.selected_slot, + if (method.slot == "a") stringResource(id = R.string.slot_a) + else stringResource(id = R.string.slot_b) + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } + } + + // KPM 状态显示卡片 + if (kpmPatchOption != KpmPatchOption.FOLLOW_KERNEL) { + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant), + elevation = getCardElevation(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + Text( + text = when (kpmPatchOption) { + KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled) + KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled) + else -> "" + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + color = when (kpmPatchOption) { + KpmPatchOption.PATCH_KPM -> MaterialTheme.colorScheme.primary + KpmPatchOption.UNDO_PATCH_KPM -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurface + } + ) + } + } + } + + Button( + modifier = Modifier.fillMaxWidth(), + enabled = installMethod != null, + onClick = onClickNext, + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + ) { + Text( + stringResource(id = R.string.install_next), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + +@Composable +private fun KpmPatchSelectionDialog( + show: Boolean, + currentOption: KpmPatchOption, + onDismiss: () -> Unit, + onOptionSelected: (KpmPatchOption) -> Unit +) { + if (show) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.kpm_patch_options)) }, + text = { + Column { + Text( + text = stringResource(R.string.kpm_patch_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + KpmPatchOptionGroup( + selectedOption = currentOption, + onOptionChanged = onOptionSelected + ) + } + }, + confirmButton = { + TextButton( + onClick = { onOptionSelected(currentOption) } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } +} + +@Composable +private fun RebootDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (show) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(id = R.string.reboot_complete_title)) }, + text = { Text(stringResource(id = R.string.reboot_complete_msg)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(id = R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(id = R.string.no)) + } + } + ) + } +} + +sealed class InstallMethod { + data class SelectFile( + val uri: Uri? = null, + @param:StringRes override val label: Int = R.string.select_file, + override val summary: String? + ) : InstallMethod() + + data object DirectInstall : InstallMethod() { + override val label: Int + get() = R.string.direct_install + } + + data object DirectInstallToInactiveSlot : InstallMethod() { + override val label: Int + get() = R.string.install_inactive_slot + } + + data class HorizonKernel( + val uri: Uri? = null, + val slot: String? = null, + @param:StringRes override val label: Int = R.string.horizon_kernel, + override val summary: String? = null + ) : InstallMethod() + + abstract val label: Int + open val summary: String? = null +} + +@Composable +private fun SelectInstallMethod( + isGKI: Boolean = false, + onSelected: (InstallMethod) -> Unit = {}, + kpmPatchOption: KpmPatchOption = KpmPatchOption.FOLLOW_KERNEL, + onKpmPatchOptionChanged: (KpmPatchOption) -> Unit = {}, + selectedMethod: InstallMethod? = null +) { + val rootAvailable = rootAvailable() + val isAbDevice = produceState(initialValue = false) { + value = isAbDevice() + }.value + val defaultPartitionName = produceState(initialValue = "boot") { + value = getDefaultPartition() + }.value + val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary) + val selectFileTip = stringResource( + id = R.string.select_file_tip, defaultPartitionName + ) + + val radioOptions = mutableListOf( + InstallMethod.SelectFile(summary = selectFileTip) + ) + + if (rootAvailable) { + radioOptions.add(InstallMethod.DirectInstall) + if (isAbDevice) { + radioOptions.add(InstallMethod.DirectInstallToInactiveSlot) + } + radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary)) + } + + var selectedOption by remember { mutableStateOf(null) } + var currentSelectingMethod by remember { mutableStateOf(null) } + + LaunchedEffect(selectedMethod) { + selectedOption = selectedMethod + } + + val selectImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> + val option = when (currentSelectingMethod) { + is InstallMethod.SelectFile -> InstallMethod.SelectFile( + uri, + summary = selectFileTip + ) + + is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel( + uri, + summary = horizonKernelSummary + ) + + else -> null + } + option?.let { opt -> + selectedOption = opt + onSelected(opt) + } + } + } + } + + val confirmDialog = rememberConfirmDialog( + onConfirm = { + selectedOption = InstallMethod.DirectInstallToInactiveSlot + onSelected(InstallMethod.DirectInstallToInactiveSlot) + }, + onDismiss = null + ) + + val dialogTitle = stringResource(id = android.R.string.dialog_alert_title) + val dialogContent = stringResource(id = R.string.install_inactive_slot_warning) + + val onClick = { option: InstallMethod -> + currentSelectingMethod = option + when (option) { + is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> { + selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/*" + putExtra( + Intent.EXTRA_MIME_TYPES, + arrayOf("application/octet-stream", "application/zip") + ) + }) + } + + is InstallMethod.DirectInstall -> { + selectedOption = option + onSelected(option) + } + + is InstallMethod.DirectInstallToInactiveSlot -> { + confirmDialog.showConfirm(dialogTitle, dialogContent) + } + } + } + + var lkmExpanded by remember { mutableStateOf(false) } + var gkiExpanded by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + // LKM 安装/修补 + if (isGKI) { + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant), + elevation = getCardElevation(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant + ) + ) { + ListItem( + leadingContent = { + Icon( + Icons.Filled.AutoFixHigh, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + headlineContent = { + Text( + stringResource(R.string.Lkm_install_methods), + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.clickable { + lkmExpanded = !lkmExpanded + } + ) + } + + AnimatedVisibility( + visible = lkmExpanded, + enter = fadeIn() + expandVertically(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp + ) + ) { + radioOptions.filter { it !is InstallMethod.HorizonKernel }.forEach { option -> + val interactionSource = remember { MutableInteractionSource() } + Surface( + color = if (option.javaClass == selectedOption?.javaClass) + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = cardAlpha) + else + MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = cardAlpha), + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(MaterialTheme.shapes.medium) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = option.javaClass == selectedOption?.javaClass, + onClick = { onClick(option) }, + role = Role.RadioButton, + indication = LocalIndication.current, + interactionSource = interactionSource + ) + .padding(vertical = 8.dp, horizontal = 12.dp) + ) { + RadioButton( + selected = option.javaClass == selectedOption?.javaClass, + onClick = null, + interactionSource = interactionSource, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { + Text( + text = stringResource(id = option.label), + style = MaterialTheme.typography.bodyLarge + ) + option.summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + } + } + + // anykernel3 刷写 + if (rootAvailable) { + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant), + elevation = getCardElevation(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant + ) + ) { + ListItem( + leadingContent = { + Icon( + Icons.Filled.FileUpload, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + headlineContent = { + Text( + stringResource(R.string.GKI_install_methods), + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.clickable { + gkiExpanded = !gkiExpanded + } + ) + } + + AnimatedVisibility( + visible = gkiExpanded, + enter = fadeIn() + expandVertically(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp + ) + ) { + radioOptions.filterIsInstance().forEach { option -> + val interactionSource = remember { MutableInteractionSource() } + Surface( + color = if (option.javaClass == selectedOption?.javaClass) + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = cardAlpha) + else + MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = cardAlpha), + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(MaterialTheme.shapes.medium) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = option.javaClass == selectedOption?.javaClass, + onClick = { onClick(option) }, + role = Role.RadioButton, + indication = LocalIndication.current, + interactionSource = interactionSource + ) + .padding(vertical = 8.dp, horizontal = 12.dp) + ) { + RadioButton( + selected = option.javaClass == selectedOption?.javaClass, + onClick = null, + interactionSource = interactionSource, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { + Text( + text = stringResource(id = option.label), + style = MaterialTheme.typography.bodyLarge + ) + option.summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + // KPM修补 + if (selectedMethod is InstallMethod.HorizonKernel && selectedMethod.uri != null) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Icon( + Icons.Filled.Security, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(R.string.kpm_patch_options), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + + Text( + stringResource(R.string.kpm_patch_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + KpmPatchOptionGroup( + selectedOption = kpmPatchOption, + onOptionChanged = onKpmPatchOptionChanged + ) + } + } + } + } + } + } +} + +@Composable +private fun KpmPatchOptionGroup( + selectedOption: KpmPatchOption, + onOptionChanged: (KpmPatchOption) -> Unit +) { + val options = listOf( + KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_file), + KpmPatchOption.PATCH_KPM to stringResource(R.string.enable_kpm_patch), + KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.enable_kpm_undo_patch) + ) + + val descriptions = mapOf( + KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_description), + KpmPatchOption.PATCH_KPM to stringResource(R.string.kpm_patch_switch_description), + KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.kpm_undo_patch_switch_description) + ) + + Column { + options.forEach { (option, label) -> + val interactionSource = remember { MutableInteractionSource() } + Surface( + color = if (option == selectedOption) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = cardAlpha) + else + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = cardAlpha), + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clip(MaterialTheme.shapes.medium) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = option == selectedOption, + onClick = { onOptionChanged(option) }, + role = Role.RadioButton, + indication = LocalIndication.current, + interactionSource = interactionSource + ) + .padding(vertical = 12.dp, horizontal = 12.dp) + ) { + RadioButton( + selected = option == selectedOption, + onClick = null, + interactionSource = interactionSource, + colors = RadioButtonDefaults.colors( + selectedColor = when (option) { + KpmPatchOption.FOLLOW_KERNEL -> MaterialTheme.colorScheme.primary + KpmPatchOption.PATCH_KPM -> MaterialTheme.colorScheme.primary + KpmPatchOption.UNDO_PATCH_KPM -> MaterialTheme.colorScheme.tertiary + }, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (option == selectedOption) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurface + ) + descriptions[option]?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = if (option == selectedOption) + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + else + MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rememberSelectKmiDialog(onSelected: (String?) -> Unit): DialogHandle { + return rememberCustomDialog { dismiss -> + val supportedKmi by produceState(initialValue = emptyList()) { + value = getSupportedKmis() + } + val options = supportedKmi.map { value -> + ListOption( + titleText = value + ) + } + + var selection by remember { mutableStateOf(null) } + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + ListDialog(state = rememberUseCaseState(visible = true, onFinishedRequest = { + onSelected(selection) + }, onCloseRequest = { + dismiss() + }), header = Header.Default( + title = stringResource(R.string.select_kmi), + ), selection = ListSelection.Single( + showRadioButtons = true, + options = options, + ) { _, option -> + selection = option.titleText + }) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + onBack: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + val cardAlpha = cardAlpha + + TopAppBar( + title = { + Text( + stringResource(R.string.install), + style = MaterialTheme.typography.titleLarge + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + windowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ), + scrollBehavior = scrollBehavior + ) +} + +private fun isKoFile(context: Context, uri: Uri): Boolean { + val seg = uri.lastPathSegment ?: "" + if (seg.endsWith(".ko", ignoreCase = true)) return true + + return try { + context.contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) { + val name = cursor.getString(idx) + name?.endsWith(".ko", ignoreCase = true) == true + } else { + false + } + } ?: false + } catch (_: Throwable) { + false + } +} + +@Preview +@Composable +fun SelectInstallPreview() { + InstallScreen(EmptyDestinationsNavigator) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt new file mode 100644 index 0000000..6f36c67 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt @@ -0,0 +1,742 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.sukisu.ultra.ui.component.* +import com.sukisu.ultra.ui.theme.* +import com.sukisu.ultra.ui.viewmodel.KpmViewModel +import com.sukisu.ultra.ui.util.* +import java.io.File +import androidx.core.content.edit +import com.sukisu.ultra.R +import java.io.FileInputStream +import java.net.* +import android.app.Activity +import androidx.compose.ui.res.painterResource + +/** + * KPM 管理界面 + * 以下内核模块功能由KernelPatch开发,经过修改后加入SukiSU Ultra的内核模块功能 + * 开发者:ShirkNeko, Liaokong + */ +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun KpmScreen( + viewModel: KpmViewModel = viewModel() +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackBarHost = remember { SnackbarHostState() } + val confirmDialog = rememberConfirmDialog() + + val listState = rememberLazyListState() + val fabVisible by rememberFabVisibilityState(listState) + + val moduleConfirmContentMap = viewModel.moduleList.associate { module -> + val moduleFileName = module.id + module.id to stringResource(R.string.confirm_uninstall_content, moduleFileName) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + val kpmInstallSuccess = stringResource(R.string.kpm_install_success) + val kpmInstallFailed = stringResource(R.string.kpm_install_failed) + val cancel = stringResource(R.string.cancel) + val uninstall = stringResource(R.string.uninstall) + val failedToCheckModuleFile = stringResource(R.string.snackbar_failed_to_check_module_file) + val kpmUninstallSuccess = stringResource(R.string.kpm_uninstall_success) + val kpmUninstallFailed = stringResource(R.string.kpm_uninstall_failed) + val kpmInstallMode = stringResource(R.string.kpm_install_mode) + val kpmInstallModeLoad = stringResource(R.string.kpm_install_mode_load) + val kpmInstallModeEmbed = stringResource(R.string.kpm_install_mode_embed) + val invalidFileTypeMessage = stringResource(R.string.invalid_file_type) + val confirmTitle = stringResource(R.string.confirm_uninstall_title_with_filename) + + var tempFileForInstall by remember { mutableStateOf(null) } + val installModeDialog = rememberCustomDialog { dismiss -> + var moduleName by remember { mutableStateOf(null) } + + LaunchedEffect(tempFileForInstall) { + tempFileForInstall?.let { tempFile -> + try { + val shell = getRootShell() + val command = "strings ${tempFile.absolutePath} | grep 'name='" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + if (result.isSuccess) { + for (line in result.out) { + if (line.startsWith("name=")) { + moduleName = line.substringAfter("name=").trim() + break + } + } + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to get module name: ${e.message}", e) + } + } + } + + AlertDialog( + onDismissRequest = { + dismiss() + tempFileForInstall?.delete() + tempFileForInstall = null + }, + title = { + Text( + text = kpmInstallMode, + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column { + moduleName?.let { + Text( + text = stringResource(R.string.kpm_install_mode_description, it), + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + scope.launch { + dismiss() + tempFileForInstall?.let { tempFile -> + handleModuleInstall( + tempFile = tempFile, + isEmbed = false, + viewModel = viewModel, + snackBarHost = snackBarHost, + kpmInstallSuccess = kpmInstallSuccess, + kpmInstallFailed = kpmInstallFailed + ) + } + tempFileForInstall = null + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(end = 4.dp) + ) + Text(kpmInstallModeLoad) + } + + Button( + onClick = { + scope.launch { + dismiss() + tempFileForInstall?.let { tempFile -> + handleModuleInstall( + tempFile = tempFile, + isEmbed = true, + viewModel = viewModel, + snackBarHost = snackBarHost, + kpmInstallSuccess = kpmInstallSuccess, + kpmInstallFailed = kpmInstallFailed + ) + } + tempFileForInstall = null + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Inventory, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(end = 4.dp) + ) + Text(kpmInstallModeEmbed) + } + } + } + }, + confirmButton = { + }, + dismissButton = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = { + dismiss() + tempFileForInstall?.delete() + tempFileForInstall = null + } + ) { + Text(cancel) + } + } + }, + shape = MaterialTheme.shapes.extraLarge + ) + } + + val selectPatchLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + + val uri = result.data?.data ?: return@rememberLauncherForActivityResult + + scope.launch { + val fileName = uri.lastPathSegment ?: "unknown.kpm" + val encodedFileName = URLEncoder.encode(fileName, "UTF-8") + val tempFile = File(context.cacheDir, encodedFileName) + + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + val mimeType = context.contentResolver.getType(uri) + val isCorrectMimeType = mimeType == null || mimeType.contains("application/octet-stream") + + if (!isCorrectMimeType) { + var shouldShowSnackbar = true + try { + val matchCount = checkStringsCommand(tempFile) + val isElf = isElfFile(tempFile) + + if (matchCount >= 1 || isElf) { + shouldShowSnackbar = false + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to execute checks: ${e.message}", e) + } + if (shouldShowSnackbar) { + snackBarHost.showSnackbar( + message = invalidFileTypeMessage, + duration = SnackbarDuration.Short + ) + } + tempFile.delete() + return@launch + } + tempFileForInstall = tempFile + installModeDialog.show() + } + } + + LaunchedEffect(Unit) { + while(true) { + viewModel.fetchModuleList() + delay(5000) + } + } + + val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) + var isNoticeClosed by remember { mutableStateOf(sharedPreferences.getBoolean("is_notice_closed", false)) } + + Scaffold( + topBar = { + SearchAppBar( + title = { Text(stringResource(R.string.kpm_title)) }, + searchText = viewModel.search, + onSearchTextChange = { viewModel.search = it }, + onClearClick = { viewModel.search = "" }, + scrollBehavior = scrollBehavior, + dropdownContent = { + IconButton( + onClick = { viewModel.fetchModuleList() } + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.refresh), + ) + } + } + ) + }, + floatingActionButton = { + AnimatedFab(visible = fabVisible) { + FloatingActionButton( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.primary, + onClick = { + selectPatchLauncher.launch( + Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/octet-stream" + } + ) + }, + content = { + Icon( + painter = painterResource(id = R.drawable.package_import), + contentDescription = null + ) + } + ) + } + }, + contentWindowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ), + snackbarHost = { SnackbarHost(snackBarHost) } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + if (!isNoticeClosed) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(MaterialTheme.shapes.medium) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + ) + + Text( + text = stringResource(R.string.kernel_module_notice), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + ) + + IconButton( + onClick = { + isNoticeClosed = true + sharedPreferences.edit { putBoolean("is_notice_closed", true) } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close_notice) + ) + } + } + } + } + + if (viewModel.moduleList.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier + .size(96.dp) + .padding(bottom = 16.dp) + ) + Text( + stringResource(R.string.kpm_empty), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(viewModel.moduleList) { module -> + KpmModuleItem( + module = module, + onUninstall = { + scope.launch { + val confirmContent = moduleConfirmContentMap[module.id] ?: "" + handleModuleUninstall( + module = module, + viewModel = viewModel, + snackBarHost = snackBarHost, + kpmUninstallSuccess = kpmUninstallSuccess, + kpmUninstallFailed = kpmUninstallFailed, + failedToCheckModuleFile = failedToCheckModuleFile, + uninstall = uninstall, + cancel = cancel, + confirmDialog = confirmDialog, + confirmTitle = confirmTitle, + confirmContent = confirmContent + ) + } + }, + onControl = { + viewModel.loadModuleDetail(module.id) + } + ) + } + } + } + } + } +} + +private suspend fun handleModuleInstall( + tempFile: File, + isEmbed: Boolean, + viewModel: KpmViewModel, + snackBarHost: SnackbarHostState, + kpmInstallSuccess: String, + kpmInstallFailed: String +) { + var moduleId: String? = null + try { + val shell = getRootShell() + val command = "strings ${tempFile.absolutePath} | grep 'name='" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + if (result.isSuccess) { + for (line in result.out) { + if (line.startsWith("name=")) { + moduleId = line.substringAfter("name=").trim() + break + } + } + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to get module ID from strings command: ${e.message}", e) + } + + if (moduleId == null || moduleId.isEmpty()) { + Log.e("KsuCli", "Failed to extract module ID from file: ${tempFile.name}") + snackBarHost.showSnackbar( + message = kpmInstallFailed, + duration = SnackbarDuration.Short + ) + tempFile.delete() + return + } + + val targetPath = "/data/adb/kpm/$moduleId.kpm" + + try { + if (isEmbed) { + val shell = getRootShell() + shell.newJob().add("mkdir -p /data/adb/kpm").exec() + shell.newJob().add("cp ${tempFile.absolutePath} $targetPath").exec() + } + + val loadResult = loadKpmModule(tempFile.absolutePath) + if (loadResult.startsWith("Error")) { + Log.e("KsuCli", "Failed to load KPM module: $loadResult") + snackBarHost.showSnackbar( + message = kpmInstallFailed, + duration = SnackbarDuration.Short + ) + } else { + viewModel.fetchModuleList() + snackBarHost.showSnackbar( + message = kpmInstallSuccess, + duration = SnackbarDuration.Short + ) + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to load KPM module: ${e.message}", e) + snackBarHost.showSnackbar( + message = kpmInstallFailed, + duration = SnackbarDuration.Short + ) + } + tempFile.delete() +} + +private suspend fun handleModuleUninstall( + module: KpmViewModel.ModuleInfo, + viewModel: KpmViewModel, + snackBarHost: SnackbarHostState, + kpmUninstallSuccess: String, + kpmUninstallFailed: String, + failedToCheckModuleFile: String, + uninstall: String, + cancel: String, + confirmTitle : String, + confirmContent : String, + confirmDialog: ConfirmDialogHandle +) { + val moduleFileName = "${module.id}.kpm" + val moduleFilePath = "/data/adb/kpm/$moduleFileName" + + val fileExists = try { + val shell = getRootShell() + val result = shell.newJob().add("ls /data/adb/kpm/$moduleFileName").exec() + result.isSuccess + } catch (e: Exception) { + Log.e("KsuCli", "Failed to check module file existence: ${e.message}", e) + snackBarHost.showSnackbar( + message = failedToCheckModuleFile, + duration = SnackbarDuration.Short + ) + false + } + + val confirmResult = confirmDialog.awaitConfirm( + title = confirmTitle, + content = confirmContent, + confirm = uninstall, + dismiss = cancel + ) + + if (confirmResult == ConfirmResult.Confirmed) { + try { + val unloadResult = unloadKpmModule(module.id) + if (unloadResult.startsWith("Error")) { + Log.e("KsuCli", "Failed to unload KPM module: $unloadResult") + snackBarHost.showSnackbar( + message = kpmUninstallFailed, + duration = SnackbarDuration.Short + ) + return + } + + if (fileExists) { + val shell = getRootShell() + shell.newJob().add("rm $moduleFilePath").exec() + } + + viewModel.fetchModuleList() + snackBarHost.showSnackbar( + message = kpmUninstallSuccess, + duration = SnackbarDuration.Short + ) + } catch (e: Exception) { + Log.e("KsuCli", "Failed to unload KPM module: ${e.message}", e) + snackBarHost.showSnackbar( + message = kpmUninstallFailed, + duration = SnackbarDuration.Short + ) + } + } +} + +@Composable +private fun KpmModuleItem( + module: KpmViewModel.ModuleInfo, + onUninstall: () -> Unit, + onControl: () -> Unit +) { + val viewModel: KpmViewModel = viewModel() + val scope = rememberCoroutineScope() + val snackBarHost = remember { SnackbarHostState() } + val successMessage = stringResource(R.string.kpm_control_success) + val failureMessage = stringResource(R.string.kpm_control_failed) + + if (viewModel.showInputDialog && viewModel.selectedModuleId == module.id) { + AlertDialog( + onDismissRequest = { viewModel.hideInputDialog() }, + title = { + Text( + text = stringResource(R.string.kpm_control), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + OutlinedTextField( + value = viewModel.inputArgs, + onValueChange = { viewModel.updateInputArgs(it) }, + label = { + Text( + text = stringResource(R.string.kpm_args), + ) + }, + placeholder = { + Text( + text = module.args, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + val result = viewModel.executeControl() + val message = when (result) { + 0 -> successMessage + else -> failureMessage + } + snackBarHost.showSnackbar(message) + onControl() + } + } + ) { + Text( + text = stringResource(R.string.confirm), + ) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.hideInputDialog() }) { + Text( + text = stringResource(R.string.cancel), + ) + } + }, + shape = MaterialTheme.shapes.extraLarge + ) + } + + Card( + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh), + elevation = getCardElevation() + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = module.name, + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "${stringResource(R.string.kpm_version)}: ${module.version}", + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = "${stringResource(R.string.kpm_author)}: ${module.author}", + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = "${stringResource(R.string.kpm_args)}: ${module.args}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = module.description, + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { viewModel.showInputDialog(module.id) }, + enabled = module.hasAction, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.kpm_control)) + } + + Button( + onClick = onUninstall, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.kpm_uninstall)) + } + } + } + } +} + +private fun checkStringsCommand(tempFile: File): Int { + val shell = getRootShell() + val command = "strings ${tempFile.absolutePath} | grep -E 'name=|version=|license=|author='" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + + if (!result.isSuccess) { + return 0 + } + + var matchCount = 0 + val keywords = listOf("name=", "version=", "license=", "author=") + var nameExists = false + + for (line in result.out) { + if (!nameExists && line.startsWith("name=")) { + nameExists = true + matchCount++ + } else if (nameExists) { + for (keyword in keywords) { + if (line.startsWith(keyword)) { + matchCount++ + break + } + } + } + } + + return if (nameExists) matchCount else 0 +} + +private fun isElfFile(tempFile: File): Boolean { + val elfMagic = byteArrayOf(0x7F, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte()) + val fileBytes = ByteArray(4) + FileInputStream(tempFile).use { input -> + input.read(fileBytes) + } + return fileBytes.contentEquals(elfMagic) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/LogViewerScreen.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/LogViewerScreen.kt new file mode 100644 index 0000000..929a8b2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/LogViewerScreen.kt @@ -0,0 +1,941 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.* +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.* +import java.time.format.DateTimeFormatter +import android.os.Process.myUid +import androidx.core.content.edit + +private val SPACING_SMALL = 4.dp +private val SPACING_MEDIUM = 8.dp +private val SPACING_LARGE = 16.dp + +private const val PAGE_SIZE = 10000 +private const val MAX_TOTAL_LOGS = 100000 + +private const val LOGS_PATCH = "/data/adb/ksu/log/sulog.log" + +data class LogEntry( + val timestamp: String, + val type: LogType, + val uid: String, + val comm: String, + val details: String, + val pid: String, + val rawLine: String +) + +data class LogPageInfo( + val currentPage: Int = 0, + val totalPages: Int = 0, + val totalLogs: Int = 0, + val hasMore: Boolean = false +) + +enum class LogType(val displayName: String, val color: Color) { + SU_GRANT("SU_GRANT", Color(0xFF4CAF50)), + SU_EXEC("SU_EXEC", Color(0xFF2196F3)), + PERM_CHECK("PERM_CHECK", Color(0xFFFF9800)), + SYSCALL("SYSCALL", Color(0xFF00BCD4)), + MANAGER_OP("MANAGER_OP", Color(0xFF9C27B0)), + UNKNOWN("UNKNOWN", Color(0xFF757575)) +} + +enum class LogExclType(val displayName: String, val color: Color) { + CURRENT_APP("Current app", Color(0xFF9E9E9E)), + PRCTL_STAR("prctl_*", Color(0xFF00BCD4)), + PRCTL_UNKNOWN("prctl_unknown", Color(0xFF00BCD4)), + SETUID("setuid", Color(0xFF00BCD4)) +} + +private val utcFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") +private val localFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + +private fun saveExcludedSubTypes(context: Context, types: Set) { + val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE) + val nameSet = types.map { it.name }.toSet() + prefs.edit { putStringSet("excluded_subtypes", nameSet) } +} + +private fun loadExcludedSubTypes(context: Context): Set { + val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE) + val nameSet = prefs.getStringSet("excluded_subtypes", emptySet()) ?: emptySet() + return nameSet.mapNotNull { name -> + LogExclType.entries.firstOrNull { it.name == name } + }.toSet() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun LogViewerScreen(navigator: DestinationsNavigator) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val snackBarHost = LocalSnackbarHost.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var logEntries by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var filterType by rememberSaveable { mutableStateOf(null) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var showSearchBar by rememberSaveable { mutableStateOf(false) } + var pageInfo by remember { mutableStateOf(LogPageInfo()) } + var lastLogFileHash by remember { mutableStateOf("") } + val currentUid = remember { myUid().toString() } + + val initialExcluded = remember { + loadExcludedSubTypes(context) + } + + var excludedSubTypes by rememberSaveable { mutableStateOf(initialExcluded) } + + LaunchedEffect(excludedSubTypes) { + saveExcludedSubTypes(context, excludedSubTypes) + } + + val filteredEntries = remember( + logEntries, filterType, searchQuery, excludedSubTypes + ) { + logEntries.filter { entry -> + val matchesSearch = searchQuery.isEmpty() || + entry.comm.contains(searchQuery, ignoreCase = true) || + entry.details.contains(searchQuery, ignoreCase = true) || + entry.uid.contains(searchQuery, ignoreCase = true) + + // 排除本应用 + if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false + + // 排除 SYSCALL 子类型 + if (entry.type == LogType.SYSCALL) { + val detail = entry.details + if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false + if (LogExclType.PRCTL_UNKNOWN in excludedSubTypes && detail.startsWith("Syscall: prctl_unknown")) return@filter false + if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false + } + + // 普通类型筛选 + val matchesFilter = filterType == null || entry.type == filterType + matchesFilter && matchesSearch + } + } + + val loadingDialog = rememberLoadingDialog() + val confirmDialog = rememberConfirmDialog() + + val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh -> + scope.launch { + if (isLoading) return@launch + + isLoading = true + try { + loadLogsWithPagination( + page, + forceRefresh, + lastLogFileHash + ) { entries, newPageInfo, newHash -> + logEntries = if (page == 0 || forceRefresh) { + entries + } else { + logEntries + entries + } + pageInfo = newPageInfo + lastLogFileHash = newHash + } + } finally { + isLoading = false + } + } + } + + val onManualRefresh: () -> Unit = { + loadPage(0, true) + } + + val loadNextPage: () -> Unit = { + if (pageInfo.hasMore && !isLoading) { + loadPage(pageInfo.currentPage + 1, false) + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(5_000) + if (!isLoading) { + scope.launch { + val hasNewLogs = checkForNewLogs(lastLogFileHash) + if (hasNewLogs) { + loadPage(0, true) + } + } + } + } + } + + LaunchedEffect(Unit) { + loadPage(0, true) + } + + Scaffold( + topBar = { + LogViewerTopBar( + scrollBehavior = scrollBehavior, + onBackClick = { navigator.navigateUp() }, + showSearchBar = showSearchBar, + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it }, + onSearchToggle = { showSearchBar = !showSearchBar }, + onRefresh = onManualRefresh, + onClearLogs = { + scope.launch { + val result = confirmDialog.awaitConfirm( + title = context.getString(R.string.log_viewer_clear_logs), + content = context.getString(R.string.log_viewer_clear_logs_confirm) + ) + if (result == ConfirmResult.Confirmed) { + loadingDialog.withLoading { + clearLogs() + loadPage(0, true) + } + snackBarHost.showSnackbar(context.getString(R.string.log_viewer_logs_cleared)) + } + } + } + ) + }, + snackbarHost = { SnackbarHost(snackBarHost) }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + LogControlPanel( + filterType = filterType, + onFilterTypeSelected = { filterType = it }, + logCount = filteredEntries.size, + totalCount = logEntries.size, + pageInfo = pageInfo, + excludedSubTypes = excludedSubTypes, + onExcludeToggle = { excl -> + excludedSubTypes = if (excl in excludedSubTypes) + excludedSubTypes - excl + else + excludedSubTypes + excl + } + ) + + // 日志列表 + if (isLoading && logEntries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (filteredEntries.isEmpty()) { + EmptyLogState( + hasLogs = logEntries.isNotEmpty(), + onRefresh = onManualRefresh + ) + } else { + LogList( + entries = filteredEntries, + pageInfo = pageInfo, + isLoading = isLoading, + onLoadMore = loadNextPage, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun LogControlPanel( + filterType: LogType?, + onFilterTypeSelected: (LogType?) -> Unit, + logCount: Int, + totalCount: Int, + pageInfo: LogPageInfo, + excludedSubTypes: Set, + onExcludeToggle: (LogExclType) -> Unit +) { + var isExpanded by rememberSaveable { mutableStateOf(true) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow), + elevation = getCardElevation() + ) { + Column { + // 标题栏(点击展开/收起) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(SPACING_LARGE), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(horizontal = SPACING_LARGE) + ) { + // 类型过滤 + Text( + text = stringResource(R.string.log_viewer_filter_type), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) { + item { + FilterChip( + onClick = { onFilterTypeSelected(null) }, + label = { Text(stringResource(R.string.log_viewer_all_types)) }, + selected = filterType == null + ) + } + items(LogType.entries.toTypedArray()) { type -> + FilterChip( + onClick = { onFilterTypeSelected(if (filterType == type) null else type) }, + label = { Text(type.displayName) }, + selected = filterType == type, + leadingIcon = { + Box( + modifier = Modifier + .size(8.dp) + .background(type.color, RoundedCornerShape(4.dp)) + ) + } + ) + } + } + + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + + // 排除子类型 + Text( + text = stringResource(R.string.log_viewer_exclude_subtypes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) { + items(LogExclType.entries.toTypedArray()) { excl -> + val label = if (excl == LogExclType.CURRENT_APP) + stringResource(R.string.log_viewer_exclude_current_app) + else excl.displayName + + FilterChip( + onClick = { onExcludeToggle(excl) }, + label = { Text(label) }, + selected = excl in excludedSubTypes, + leadingIcon = { + Box( + modifier = Modifier + .size(8.dp) + .background(excl.color, RoundedCornerShape(4.dp)) + ) + } + ) + } + } + + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + + // 统计信息 + Column(verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)) { + Text( + text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (pageInfo.totalPages > 0) { + Text( + text = stringResource( + R.string.log_viewer_page_info, + pageInfo.currentPage + 1, + pageInfo.totalPages, + pageInfo.totalLogs + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) { + Text( + text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + Spacer(modifier = Modifier.height(SPACING_LARGE)) + } + } + } + } +} + +@Composable +private fun LogList( + entries: List, + pageInfo: LogPageInfo, + isLoading: Boolean, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + verticalArrangement = Arrangement.spacedBy(SPACING_SMALL) + ) { + items(entries) { entry -> + LogEntryCard(entry = entry) + } + + // 加载更多按钮或加载指示器 + if (pageInfo.hasMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp) + ) + } else { + Button( + onClick = onLoadMore, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(stringResource(R.string.log_viewer_load_more)) + } + } + } + } + } else if (entries.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.log_viewer_all_logs_loaded), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun LogEntryCard(entry: LogEntry) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(SPACING_MEDIUM) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM) + ) { + Box( + modifier = Modifier + .size(12.dp) + .background(entry.type.color, RoundedCornerShape(6.dp)) + ) + Text( + text = entry.type.displayName, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + Text( + text = entry.timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(SPACING_SMALL)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "UID: ${entry.uid}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "PID: ${entry.pid}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = entry.comm, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = if (expanded) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis + ) + + if (entry.details.isNotEmpty()) { + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = entry.details, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = if (expanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + Text( + text = stringResource(R.string.log_viewer_raw_log), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = entry.rawLine, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun EmptyLogState( + hasLogs: Boolean, + onRefresh: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(SPACING_LARGE) + ) { + Icon( + imageVector = if (hasLogs) Icons.Filled.FilterList else Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource( + if (hasLogs) R.string.log_viewer_no_matching_logs + else R.string.log_viewer_no_logs + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(onClick = onRefresh) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(stringResource(R.string.log_viewer_refresh)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogViewerTopBar( + scrollBehavior: TopAppBarScrollBehavior? = null, + onBackClick: () -> Unit, + showSearchBar: Boolean, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSearchToggle: () -> Unit, + onRefresh: () -> Unit, + onClearLogs: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + + Column { + TopAppBar( + title = { + Text( + text = stringResource(R.string.log_viewer_title), + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.log_viewer_back) + ) + } + }, + actions = { + IconButton(onClick = onSearchToggle) { + Icon( + imageVector = if (showSearchBar) Icons.Filled.SearchOff else Icons.Filled.Search, + contentDescription = stringResource(R.string.log_viewer_search) + ) + } + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.log_viewer_refresh) + ) + } + IconButton(onClick = onClearLogs) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = stringResource(R.string.log_viewer_clear_logs) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) + + AnimatedVisibility( + visible = showSearchBar, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + placeholder = { Text(stringResource(R.string.log_viewer_search_placeholder)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.log_viewer_clear_search) + ) + } + } + }, + singleLine = true + ) + } + } +} + +private suspend fun checkForNewLogs( + lastHash: String +): Boolean { + return withContext(Dispatchers.IO) { + try { + val shell = getRootShell() + val logPath = "/data/adb/ksu/log/sulog.log" + + val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'") + val currentHash = result.trim() + + currentHash != lastHash && currentHash != "0 0" + } catch (_: Exception) { + false + } + } +} + +private suspend fun loadLogsWithPagination( + page: Int, + forceRefresh: Boolean, + lastHash: String, + onLoaded: (List, LogPageInfo, String) -> Unit +) { + withContext(Dispatchers.IO) { + try { + val shell = getRootShell() + + // 获取文件信息 + val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'") + val currentHash = statResult.trim() + + if (!forceRefresh && currentHash == lastHash && currentHash != "0 0") { + withContext(Dispatchers.Main) { + onLoaded(emptyList(), LogPageInfo(), currentHash) + } + return@withContext + } + + // 获取总行数 + val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'") + val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0 + + if (totalLines == 0) { + withContext(Dispatchers.Main) { + onLoaded(emptyList(), LogPageInfo(), currentHash) + } + return@withContext + } + + // 限制最大日志数量 + val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS) + val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE + + // 计算要读取的行数范围 + val startLine = if (page == 0) { + maxOf(1, totalLines - effectiveTotal + 1) + } else { + val skipLines = page * PAGE_SIZE + maxOf(1, totalLines - effectiveTotal + 1 + skipLines) + } + + val endLine = minOf(startLine + PAGE_SIZE - 1, totalLines) + + if (startLine > totalLines) { + withContext(Dispatchers.Main) { + onLoaded(emptyList(), LogPageInfo(page, totalPages, effectiveTotal, false), currentHash) + } + return@withContext + } + + val result = runCmd(shell, "sed -n '${startLine},${endLine}p' $LOGS_PATCH 2>/dev/null || echo ''") + val entries = parseLogEntries(result) + + val hasMore = endLine < totalLines + val pageInfo = LogPageInfo(page, totalPages, effectiveTotal, hasMore) + + withContext(Dispatchers.Main) { + onLoaded(entries, pageInfo, currentHash) + } + } catch (_: Exception) { + withContext(Dispatchers.Main) { + onLoaded(emptyList(), LogPageInfo(), lastHash) + } + } + } +} + +private suspend fun clearLogs() { + withContext(Dispatchers.IO) { + try { + val shell = getRootShell() + runCmd(shell, "echo '' > $LOGS_PATCH") + } catch (_: Exception) { + } + } +} + +private fun parseLogEntries(logContent: String): List { + if (logContent.isBlank()) return emptyList() + + val entries = logContent.lines() + .filter { it.isNotBlank() && it.startsWith("[") } + .mapNotNull { line -> + try { + parseLogLine(line) + } catch (_: Exception) { + null + } + } + + return entries.reversed() +} +private fun utcToLocal(utc: String): String { + return try { + val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant() + val local = instant.atZone(ZoneId.systemDefault()) + local.format(localFormatter) + } catch (_: Exception) { + utc + } +} + +private fun parseLogLine(line: String): LogEntry? { + // 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ... + val timestampRegex = """\[(.*?)]""".toRegex() + val timestampMatch = timestampRegex.find(line) ?: return null + val timestamp = utcToLocal(timestampMatch.groupValues[1]) + + val afterTimestamp = line.substring(timestampMatch.range.last + 1).trim() + val parts = afterTimestamp.split(":") + if (parts.size < 2) return null + + val typeStr = parts[0].trim() + val type = when (typeStr) { + "SU_GRANT" -> LogType.SU_GRANT + "SU_EXEC" -> LogType.SU_EXEC + "PERM_CHECK" -> LogType.PERM_CHECK + "SYSCALL" -> LogType.SYSCALL + "MANAGER_OP" -> LogType.MANAGER_OP + else -> LogType.UNKNOWN + } + + val details = parts[1].trim() + val uid: String = extractValue(details, "UID") ?: "" + val comm: String = extractValue(details, "COMM") ?: "" + val pid: String = extractValue(details, "PID") ?: "" + + // 构建详细信息字符串 + val detailsStr = when (type) { + LogType.SU_GRANT -> { + val method: String = extractValue(details, "METHOD") ?: "" + "Method: $method" + } + LogType.SU_EXEC -> { + val target: String = extractValue(details, "TARGET") ?: "" + val result: String = extractValue(details, "RESULT") ?: "" + "Target: $target, Result: $result" + } + LogType.PERM_CHECK -> { + val result: String = extractValue(details, "RESULT") ?: "" + "Result: $result" + } + LogType.SYSCALL -> { + val syscall = extractValue(details, "SYSCALL") ?: "" + val args = extractValue(details, "ARGS") ?: "" + "Syscall: $syscall, Args: $args" + } + LogType.MANAGER_OP -> { + val op: String = extractValue(details, "OP") ?: "" + val managerUid: String = extractValue(details, "MANAGER_UID") ?: "" + val targetUid: String = extractValue(details, "TARGET_UID") ?: "" + "Operation: $op, Manager UID: $managerUid, Target UID: $targetUid" + } + else -> details + } + + return LogEntry( + timestamp = timestamp, + type = type, + uid = uid, + comm = comm, + details = detailsStr, + pid = pid, + rawLine = line + ) +} + +private fun extractValue(text: String, key: String): String? { + val regex = """$key=(\S+)""".toRegex() + return regex.find(text)?.groupValues?.get(1) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt new file mode 100644 index 0000000..65d8574 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt @@ -0,0 +1,1340 @@ +package com.sukisu.ultra.ui.screen + +import android.annotation.SuppressLint +import android.app.Activity.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Wysiwyg +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import com.dergoogler.mmrl.platform.Platform +import com.dergoogler.mmrl.platform.model.ModuleConfig +import com.dergoogler.mmrl.platform.model.ModuleConfig.Companion.asModuleConfig +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination +import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.* +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import com.sukisu.ultra.ui.util.module.ModuleModify +import com.sukisu.ultra.ui.util.module.ModuleOperationUtils +import com.sukisu.ultra.ui.util.module.ModuleUtils +import com.sukisu.ultra.ui.util.module.verifyModuleSignature +import com.sukisu.ultra.ui.viewmodel.ModuleViewModel +import com.sukisu.ultra.ui.webui.WebUIActivity +import com.sukisu.ultra.ui.webui.WebUIXActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +data class ModuleBottomSheetMenuItem( + val icon: ImageVector, + val titleRes: Int, + val onClick: () -> Unit +) + +/** + * @author ShirkNeko + * @date 2025/9/29. + */ +@SuppressLint("ResourceType", "AutoboxingStateCreation") +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun ModuleScreen(navigator: DestinationsNavigator) { + val viewModel = viewModel() + val context = LocalContext.current + val prefs = context.getSharedPreferences("settings", MODE_PRIVATE) + val snackBarHost = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val confirmDialog = rememberConfirmDialog() + var lastClickTime by remember { mutableStateOf(0L) } + + var showSignatureDialog by remember { mutableStateOf(false) } + var signatureDialogMessage by remember { mutableStateOf("") } + var isForceVerificationFailed by remember { mutableStateOf(false) } + var pendingInstallAction by remember { mutableStateOf<(() -> Unit)?>(null) } + + LaunchedEffect(Unit) { + viewModel.initializeCache(context) + } + + val bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + var showBottomSheet by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val fabVisible by rememberFabVisibilityState(listState) + + val selectZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode != RESULT_OK) { + return@rememberLauncherForActivityResult + } + val data = it.data ?: return@rememberLauncherForActivityResult + + scope.launch { + val clipData = data.clipData + if (clipData != null) { + val selectedModules = mutableListOf() + val selectedModuleNames = mutableMapOf() + + fun processUri(uri: Uri) { + try { + if (!ModuleUtils.isUriAccessible(context, uri)) { + return + } + ModuleUtils.takePersistableUriPermission(context, uri) + val moduleName = ModuleUtils.extractModuleName(context, uri) + selectedModules.add(uri) + selectedModuleNames[uri] = moduleName + } catch (e: Exception) { + Log.e("ModuleScreen", "Error while processing URI: $uri, Error: ${e.message}") + } + } + + for (i in 0 until clipData.itemCount) { + val uri = clipData.getItemAt(i).uri + processUri(uri) + } + + if (selectedModules.isEmpty()) { + snackBarHost.showSnackbar("Unable to access selected module files") + return@launch + } + + val modulesList = selectedModuleNames.values.joinToString("\n• ", "• ") + val confirmResult = confirmDialog.awaitConfirm( + title = context.getString(R.string.module_install), + content = context.getString(R.string.module_install_multiple_confirm_with_names, selectedModules.size, modulesList), + confirm = context.getString(R.string.install), + dismiss = context.getString(R.string.cancel) + ) + + if (confirmResult == ConfirmResult.Confirmed) { + // 验证模块签名 + val forceVerification = prefs.getBoolean("force_signature_verification", false) + val verificationResults = mutableMapOf() + + for (uri in selectedModules) { + val isVerified = verifyModuleSignature(context, uri) + verificationResults[uri] = isVerified + // 存储验证状态 + setModuleVerificationStatus(uri, isVerified) + + if (forceVerification && !isVerified) { + withContext(Dispatchers.Main) { + signatureDialogMessage = context.getString(R.string.module_signature_invalid_message) + isForceVerificationFailed = true + showSignatureDialog = true + } + return@launch + } else if (!isVerified) { + withContext(Dispatchers.Main) { + signatureDialogMessage = context.getString(R.string.module_signature_verification_failed) + isForceVerificationFailed = false + pendingInstallAction = { + try { + navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(selectedModules))) + viewModel.markNeedRefresh() + } catch (e: Exception) { + Log.e("ModuleScreen", "Error navigating to FlashScreen: ${e.message}") + scope.launch { + snackBarHost.showSnackbar("Error while installing module: ${e.message}") + } + } + } + showSignatureDialog = true + } + return@launch + } + } + + // 所有模块签名验证通过,直接安装 + if (verificationResults.all { it -> it.value }) { + try { + navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(selectedModules))) + viewModel.markNeedRefresh() + } catch (e: Exception) { + Log.e("ModuleScreen", "Error navigating to FlashScreen: ${e.message}") + snackBarHost.showSnackbar("Error while installing module: ${e.message}") + } + } + } + } else { + val uri = data.data ?: return@launch + // 单个安装模块 + try { + if (!ModuleUtils.isUriAccessible(context, uri)) { + snackBarHost.showSnackbar("Unable to access selected module files") + return@launch + } + + ModuleUtils.takePersistableUriPermission(context, uri) + + val moduleName = ModuleUtils.extractModuleName(context, uri) + + val confirmResult = confirmDialog.awaitConfirm( + title = context.getString(R.string.module_install), + content = context.getString(R.string.module_install_confirm, moduleName), + confirm = context.getString(R.string.install), + dismiss = context.getString(R.string.cancel) + ) + + if (confirmResult == ConfirmResult.Confirmed) { + // 验证模块签名 + val forceVerification = prefs.getBoolean("force_signature_verification", false) + val isVerified = verifyModuleSignature(context, uri) + // 存储验证状态 + setModuleVerificationStatus(uri, isVerified) + + if (forceVerification && !isVerified) { + signatureDialogMessage = context.getString(R.string.module_signature_invalid_message) + isForceVerificationFailed = true + showSignatureDialog = true + return@launch + } else if (!isVerified) { + signatureDialogMessage = context.getString(R.string.module_signature_verification_failed) + isForceVerificationFailed = false + pendingInstallAction = { + navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(uri))) + viewModel.markNeedRefresh() + } + showSignatureDialog = true + return@launch + } + + navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(uri))) + viewModel.markNeedRefresh() + } + } catch (e: Exception) { + Log.e("ModuleScreen", "Error processing a single URI: $uri, Error: ${e.message}") + snackBarHost.showSnackbar("Error processing module file: ${e.message}") + } + } + } + } + + val backupLauncher = ModuleModify.rememberModuleBackupLauncher(context, snackBarHost) + val restoreLauncher = ModuleModify.rememberModuleRestoreLauncher(context, snackBarHost) + + LaunchedEffect(Unit) { + if (viewModel.moduleList.isEmpty() || viewModel.isNeedRefresh) { + viewModel.sortEnabledFirst = prefs.getBoolean("module_sort_enabled_first", false) + viewModel.sortActionFirst = prefs.getBoolean("module_sort_action_first", false) + viewModel.fetchModuleList() + } + } + + val isSafeMode = Natives.isSafeMode + val hasMagisk = hasMagisk() + val hideInstallButton = isSafeMode || hasMagisk + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + val webUILauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { viewModel.fetchModuleList() } + + val bottomSheetMenuItems = remember { + listOf( + ModuleBottomSheetMenuItem( + icon = Icons.Outlined.Save, + titleRes = R.string.backup_modules, + onClick = { + backupLauncher.launch(ModuleModify.createBackupIntent()) + scope.launch { + bottomSheetState.hide() + showBottomSheet = false + } + } + ), + ModuleBottomSheetMenuItem( + icon = Icons.Outlined.RestoreFromTrash, + titleRes = R.string.restore_modules, + onClick = { + restoreLauncher.launch(ModuleModify.createRestoreIntent()) + scope.launch { + bottomSheetState.hide() + showBottomSheet = false + } + } + ) + ) + } + + Scaffold( + topBar = { + SearchAppBar( + title = { Text(stringResource(R.string.module)) }, + searchText = viewModel.search, + onSearchTextChange = { viewModel.search = it }, + onClearClick = { viewModel.search = "" }, + dropdownContent = { + IconButton( + onClick = { showBottomSheet = true }, + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(id = R.string.settings), + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedFab(visible = !hideInstallButton && fabVisible) { + FloatingActionButton( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.primary, + onClick = { + selectZipLauncher.launch( + Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/zip" + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + ) + }, + content = { + Icon( + painter = painterResource(id = R.drawable.package_import), + contentDescription = null + ) + } + ) + } + }, + contentWindowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ), + snackbarHost = { SnackbarHost(hostState = snackBarHost) } + ) { innerPadding -> + when { + hasMagisk -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + modifier = Modifier + .size(64.dp) + .padding(bottom = 16.dp) + ) + Text( + stringResource(R.string.module_magisk_conflict), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + else -> { + ModuleList( + navigator = navigator, + viewModel = viewModel, + listState = listState, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + boxModifier = Modifier.padding(innerPadding), + onInstallModule = { + navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(it))) + }, + onUpdateModule = { + navigator.navigate(FlashScreenDestination(FlashIt.FlashModuleUpdate(it))) + }, + onClickModule = { id, name, hasWebUi -> + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime < 600) { + Log.d("ModuleScreen", "Click too fast, ignoring") + return@ModuleList + } + lastClickTime = currentTime + + if (hasWebUi) { + try { + val wxEngine = Intent(context, WebUIXActivity::class.java) + .setData("kernelsu://webuix/$id".toUri()) + .putExtra("id", id) + .putExtra("name", name) + + val ksuEngine = Intent(context, WebUIActivity::class.java) + .setData("kernelsu://webui/$id".toUri()) + .putExtra("id", id) + .putExtra("name", name) + + val config = try { + id.asModuleConfig + } catch (e: Exception) { + Log.e("ModuleScreen", "Failed to get config from id: $id", e) + null + } + + val globalEngine = prefs.getString("webui_engine", "default") ?: "default" + val moduleEngine = config?.getWebuiEngine(context) + val selectedEngine = when (globalEngine) { + "wx" -> wxEngine + "ksu" -> ksuEngine + "default" -> { + when (moduleEngine) { + "wx" -> wxEngine + "ksu" -> ksuEngine + else -> { + if (Platform.isAlive) { + wxEngine + } else { + ksuEngine + } + } + } + } + else -> ksuEngine + } + webUILauncher.launch(selectedEngine) + } catch (e: Exception) { + Log.e("ModuleScreen", "Error launching WebUI: ${e.message}", e) + scope.launch { + snackBarHost.showSnackbar("Error launching WebUI: ${e.message}") + } + } + return@ModuleList + } + }, + context = context, + snackBarHost = snackBarHost + ) + } + } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + }, + sheetState = bottomSheetState, + dragHandle = { + Surface( + modifier = Modifier.padding(vertical = 11.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp) + ) { + Box( + Modifier.size( + width = 32.dp, + height = 4.dp + ) + ) + } + } + ) { + ModuleBottomSheetContent( + menuItems = bottomSheetMenuItems, + viewModel = viewModel, + prefs = prefs, + scope = scope, + bottomSheetState = bottomSheetState, + onDismiss = { showBottomSheet = false } + ) + } + } + + // 签名验证弹窗 + if (showSignatureDialog) { + AlertDialog( + onDismissRequest = { showSignatureDialog = false }, + icon = { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text( + text = stringResource(R.string.module_signature_invalid), + color = MaterialTheme.colorScheme.error + ) + }, + text = { + Text(text = signatureDialogMessage) + }, + confirmButton = { + if (isForceVerificationFailed) { + // 强制验证失败,只显示确定按钮 + TextButton( + onClick = { showSignatureDialog = false } + ) { + Text(stringResource(R.string.confirm)) + } + } else { + // 非强制验证失败,显示继续安装按钮 + TextButton( + onClick = { + showSignatureDialog = false + pendingInstallAction?.invoke() + pendingInstallAction = null + } + ) { + Text(stringResource(R.string.install)) + } + } + }, + dismissButton = if (!isForceVerificationFailed) { + { + TextButton( + onClick = { + showSignatureDialog = false + pendingInstallAction = null + } + ) { + Text(stringResource(R.string.cancel)) + } + } + } else { + null + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModuleBottomSheetContent( + menuItems: List, + viewModel: ModuleViewModel, + prefs: android.content.SharedPreferences, + scope: kotlinx.coroutines.CoroutineScope, + bottomSheetState: SheetState, + onDismiss: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + // 标题 + Text( + text = stringResource(R.string.menu_options), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + + // 菜单选项网格 + LazyVerticalGrid( + columns = GridCells.Fixed(4), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(menuItems) { menuItem -> + ModuleBottomSheetMenuItemView( + menuItem = menuItem + ) + } + } + + // 排序选项 + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp)) + + Text( + text = stringResource(R.string.sort_options), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 优先显示有操作的模块 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.module_sort_action_first), + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = viewModel.sortActionFirst, + onCheckedChange = { checked -> + viewModel.sortActionFirst = checked + prefs.edit { + putBoolean("module_sort_action_first", checked) + } + scope.launch { + viewModel.fetchModuleList() + bottomSheetState.hide() + onDismiss() + } + } + ) + } + + // 优先显示已启用的模块 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.module_sort_enabled_first), + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = viewModel.sortEnabledFirst, + onCheckedChange = { checked -> + viewModel.sortEnabledFirst = checked + prefs.edit { + putBoolean("module_sort_enabled_first", checked) + } + scope.launch { + viewModel.fetchModuleList() + bottomSheetState.hide() + onDismiss() + } + } + ) + } + } + } +} + +@Composable +private fun ModuleBottomSheetMenuItemView(menuItem: ModuleBottomSheetMenuItem) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1.0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "menuItemScale" + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .clickable( + interactionSource = interactionSource, + indication = null + ) { menuItem.onClick() } + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = menuItem.icon, + contentDescription = stringResource(menuItem.titleRes), + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(menuItem.titleRes), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2 + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModuleList( + navigator: DestinationsNavigator, + viewModel: ModuleViewModel, + listState: LazyListState, + modifier: Modifier = Modifier, + boxModifier: Modifier = Modifier, + onInstallModule: (Uri) -> Unit, + onUpdateModule: (Uri) -> Unit, + onClickModule: (id: String, name: String, hasWebUi: Boolean) -> Unit, + context: Context, + snackBarHost: SnackbarHostState +) { + val failedEnable = stringResource(R.string.module_failed_to_enable) + val failedDisable = stringResource(R.string.module_failed_to_disable) + val failedUninstall = stringResource(R.string.module_uninstall_failed) + val successUninstall = stringResource(R.string.module_uninstall_success) + val reboot = stringResource(R.string.reboot) + val rebootToApply = stringResource(R.string.reboot_to_apply) + val moduleStr = stringResource(R.string.module) + val uninstall = stringResource(R.string.uninstall) + val cancel = stringResource(android.R.string.cancel) + val moduleUninstallConfirm = stringResource(R.string.module_uninstall_confirm) + val updateText = stringResource(R.string.module_update) + val changelogText = stringResource(R.string.module_changelog) + val downloadingText = stringResource(R.string.module_downloading) + val startDownloadingText = stringResource(R.string.module_start_downloading) + val fetchChangeLogFailed = stringResource(R.string.module_changelog_failed) + val downloadErrorText = stringResource(R.string.module_download_error) + + val loadingDialog = rememberLoadingDialog() + val confirmDialog = rememberConfirmDialog() + + suspend fun onModuleUpdate( + module: ModuleViewModel.ModuleInfo, + changelogUrl: String, + downloadUrl: String, + fileName: String + ) { + val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val request = okhttp3.Request.Builder() + .url(changelogUrl) + .header("User-Agent", "SukiSU-Ultra/2.0") + .build() + + val changelogResult = loadingDialog.withLoading { + withContext(Dispatchers.IO) { + runCatching { + client.newCall(request).execute().body!!.string() + } + } + } + + val showToast: suspend (String) -> Unit = { msg -> + withContext(Dispatchers.Main) { + Toast.makeText( + context, + msg, + Toast.LENGTH_SHORT + ).show() + } + } + + val changelog = changelogResult.getOrElse { + showToast(fetchChangeLogFailed.format(it.message)) + return + }.ifBlank { + showToast(fetchChangeLogFailed.format(module.name)) + return + } + + val confirmResult = confirmDialog.awaitConfirm( + changelogText, + content = changelog, + markdown = true, + confirm = updateText, + ) + + if (confirmResult != ConfirmResult.Confirmed) { + return + } + + showToast(startDownloadingText.format(module.name)) + + val downloading = downloadingText.format(module.name) + withContext(Dispatchers.IO) { + download( + context, + downloadUrl, + fileName, + downloading, + onDownloaded = { uri -> + // 验证更新模块的签名 + val isVerified = verifyModuleSignature(context, uri) + setModuleVerificationStatus(uri, isVerified) + onUpdateModule(uri) + }, + onDownloading = { + launch(Dispatchers.Main) { + Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show() + } + }, + onError = { errorMsg -> + launch(Dispatchers.Main) { + Toast.makeText(context, "$downloadErrorText: $errorMsg", Toast.LENGTH_LONG).show() + } + } + ) + } + } + + suspend fun onModuleUninstallClicked(module: ModuleViewModel.ModuleInfo) { + val isUninstall = !module.remove + if (isUninstall) { + val confirmResult = confirmDialog.awaitConfirm( + moduleStr, + content = moduleUninstallConfirm.format(module.name), + confirm = uninstall, + dismiss = cancel + ) + if (confirmResult != ConfirmResult.Confirmed) { + return + } + } + + val success = loadingDialog.withLoading { + withContext(Dispatchers.IO) { + if (isUninstall) { + // 卸载时移除验证标志 + ModuleOperationUtils.handleModuleUninstall(module.dirId) + uninstallModule(module.dirId) + } else { + undoUninstallModule(module.dirId) + } + } + } + + if (success) { + viewModel.fetchModuleList() + viewModel.markNeedRefresh() + } + if (!isUninstall) return + val message = if (success) { + successUninstall.format(module.name) + } else { + failedUninstall.format(module.name) + } + val actionLabel = if (success) { + reboot + } else { + null + } + val result = snackBarHost.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + reboot() + } + } + + PullToRefreshBox( + modifier = boxModifier, + onRefresh = { + viewModel.fetchModuleList() + }, + isRefreshing = viewModel.isRefreshing + ) { + LazyColumn( + state = listState, + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = remember { + PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = 16.dp + 56.dp + 16.dp + 48.dp + 6.dp /* Scaffold Fab Spacing + Fab container height + SnackBar height */ + ) + }, + ) { + when { + viewModel.moduleList.isEmpty() -> { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Outlined.Extension, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier + .size(96.dp) + .padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.module_empty), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + else -> { + items(viewModel.moduleList) { module -> + val scope = rememberCoroutineScope() + val updatedModule by produceState(initialValue = Triple("", "", "")) { + scope.launch(Dispatchers.IO) { + value = viewModel.checkUpdate(module) + } + } + + ModuleItem( + navigator = navigator, + module = module, + updateUrl = updatedModule.first, + onUninstallClicked = { + scope.launch { onModuleUninstallClicked(module) } + }, + onCheckChanged = { + scope.launch { + val success = withContext(Dispatchers.IO) { + toggleModule(module.dirId, !module.enabled) + } + if (success) { + viewModel.fetchModuleList() + + val result = snackBarHost.showSnackbar( + message = rebootToApply, + actionLabel = reboot, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + reboot() + } + } else { + val message = if (module.enabled) failedDisable else failedEnable + snackBarHost.showSnackbar(message.format(module.name)) + } + } + }, + onUpdate = { + scope.launch { + onModuleUpdate( + module, + updatedModule.third, + updatedModule.first, + "${module.name}-${updatedModule.second}.zip" + ) + } + }, + onClick = { + onClickModule(it.dirId, it.name, it.hasWebUi) + } + ) + + Spacer(Modifier.height(1.dp)) + } + } + } + } + + DownloadListener(context, onInstallModule) + } +} + +@Composable +fun ModuleItem( + navigator: DestinationsNavigator, + module: ModuleViewModel.ModuleInfo, + updateUrl: String, + onUninstallClicked: (ModuleViewModel.ModuleInfo) -> Unit, + onCheckChanged: (Boolean) -> Unit, + onUpdate: (ModuleViewModel.ModuleInfo) -> Unit, + onClick: (ModuleViewModel.ModuleInfo) -> Unit +) { + val context = LocalContext.current + val prefs = context.getSharedPreferences("settings", MODE_PRIVATE) + val isHideTagRow = prefs.getBoolean("is_hide_tag_row", false) + // 获取显示更多模块信息的设置 + val showMoreModuleInfo = prefs.getBoolean("show_more_module_info", false) + + // 剪贴板管理器和触觉反馈 + val clipboardManager = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val hapticFeedback = LocalHapticFeedback.current + + ElevatedCard( + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh), + elevation = getCardElevation(), + ) { + val textDecoration = if (!module.remove) null else TextDecoration.LineThrough + val interactionSource = remember { MutableInteractionSource() } + val indication = LocalIndication.current + val viewModel = viewModel() + + val sizeStr = remember(module.dirId) { + viewModel.getModuleSize(module.dirId) + } + + Column( + modifier = Modifier + .run { + if (module.hasWebUi) { + toggleable( + value = module.enabled, + enabled = !module.remove && module.enabled, + interactionSource = interactionSource, + role = Role.Button, + indication = indication, + onValueChange = { onClick(module) } + ) + } else { + this + } + } + .padding(22.dp, 18.dp, 22.dp, 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val moduleVersion = stringResource(id = R.string.module_version) + val moduleAuthor = stringResource(id = R.string.module_author) + + Column( + modifier = Modifier.fillMaxWidth(0.8f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = module.name, + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontWeight = FontWeight.SemiBold, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + fontFamily = MaterialTheme.typography.titleMedium.fontFamily, + textDecoration = textDecoration, + modifier = Modifier.weight(1f, false) + ) + + // 显示验证标签 + if (module.isVerified) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Icon( + imageVector = Icons.Default.Verified, + contentDescription = stringResource(R.string.module_signature_verified), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.module_verified), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Medium + ) + } + } + } + } + + Text( + text = "$moduleVersion: ${module.version}", + fontSize = MaterialTheme.typography.bodySmall.fontSize, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + fontFamily = MaterialTheme.typography.bodySmall.fontFamily, + textDecoration = textDecoration, + ) + + Text( + text = "$moduleAuthor: ${module.author}", + fontSize = MaterialTheme.typography.bodySmall.fontSize, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + fontFamily = MaterialTheme.typography.bodySmall.fontFamily, + textDecoration = textDecoration, + ) + + // 显示更多模块信息时添加updateJson + if (showMoreModuleInfo && module.updateJson.isNotEmpty()) { + val updateJsonLabel = stringResource(R.string.module_update_json) + Text( + text = "$updateJsonLabel: ${module.updateJson}", + fontSize = MaterialTheme.typography.bodySmall.fontSize, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + fontFamily = MaterialTheme.typography.bodySmall.fontFamily, + textDecoration = textDecoration, + color = MaterialTheme.colorScheme.primary, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { }, + onLongClick = { + val clipData = ClipData.newPlainText( + "Update JSON URL", + module.updateJson + ) + clipboardManager.setPrimaryClip(clipData) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + Toast.makeText( + context, + context.getString(R.string.module_update_json_copied), + Toast.LENGTH_SHORT + ).show() + } + ), + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Switch( + enabled = !module.update, + checked = module.enabled, + onCheckedChange = onCheckChanged, + interactionSource = if (!module.hasWebUi) interactionSource else null, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = module.description, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + fontFamily = MaterialTheme.typography.bodySmall.fontFamily, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + fontWeight = MaterialTheme.typography.bodySmall.fontWeight, + overflow = TextOverflow.Ellipsis, + maxLines = 4, + textDecoration = textDecoration, + ) + + if (!isHideTagRow) { + Spacer(modifier = Modifier.height(12.dp)) + // 文件夹名称和大小标签 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + ) { + Text( + text = module.dirId, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier + ) { + Text( + text = sizeStr, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1 + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider(thickness = Dp.Hairline) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (module.hasActionScript) { + FilledTonalButton( + modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp), + enabled = !module.remove && module.enabled, + onClick = { + navigator.navigate(ExecuteModuleActionScreenDestination(module.dirId)) + viewModel.markNeedRefresh() + }, + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.PlayArrow, + contentDescription = null + ) + } + } + + if (module.hasWebUi) { + FilledTonalButton( + modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp), + enabled = !module.remove && module.enabled, + onClick = { onClick(module) }, + interactionSource = interactionSource, + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.AutoMirrored.Outlined.Wysiwyg, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.weight(1f, true)) + + if (updateUrl.isNotEmpty()) { + Button( + modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp), + enabled = !module.remove, + onClick = { onUpdate(module) }, + shape = ButtonDefaults.textShape, + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Download, + contentDescription = null + ) + } + } + + FilledTonalButton( + modifier = Modifier.defaultMinSize(minWidth = 52.dp, minHeight = 32.dp), + onClick = { onUninstallClicked(module) }, + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + if (!module.remove) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Delete, + contentDescription = null, + ) + } else { + Icon( + modifier = Modifier.size(20.dp).rotate(180f), + imageVector = Icons.Outlined.Refresh, + contentDescription = null + ) + } + } + } + } + } +} + +@Preview +@Composable +fun ModuleItemPreview() { + val module = ModuleViewModel.ModuleInfo( + id = "id", + name = "name", + version = "version", + versionCode = 1, + author = "author", + description = "I am a test module and i do nothing but show a very long description", + enabled = true, + update = true, + remove = false, + updateJson = "", + hasWebUi = false, + hasActionScript = false, + dirId = "dirId", + config = ModuleConfig(), + isVerified = true, + verificationTimestamp = System.currentTimeMillis() + ) + ModuleItem(EmptyDestinationsNavigator, module, "", {}, {}, {}, {}) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt new file mode 100644 index 0000000..ae56e6c --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt @@ -0,0 +1,1051 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.rounded.EnhancedEncryption +import androidx.compose.material.icons.rounded.FolderDelete +import androidx.compose.material.icons.rounded.RemoveCircle +import androidx.compose.material.icons.rounded.RemoveModerator +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.content.edit +import com.maxkeppeker.sheets.core.models.base.IconSource +import com.maxkeppeler.sheets.list.models.ListOption +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination +import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.generated.destinations.LogViewerScreenDestination +import com.ramcosta.composedestinations.generated.destinations.UmountManagerScreenDestination +import com.ramcosta.composedestinations.generated.destinations.MoreSettingsScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.BuildConfig +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.* +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * @author ShirkNeko + * @date 2025/9/29. + */ +private val SPACING_SMALL = 3.dp +private val SPACING_MEDIUM = 8.dp +private val SPACING_LARGE = 16.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun SettingScreen(navigator: DestinationsNavigator) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val snackBarHost = LocalSnackbarHost.current + val context = LocalContext.current + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + var isSuLogEnabled by remember { mutableStateOf(Natives.isSuLogEnabled()) } + var selectedEngine by rememberSaveable { + mutableStateOf( + prefs.getString("webui_engine", "default") ?: "default" + ) + } + + Scaffold( + topBar = { + TopBar(scrollBehavior = scrollBehavior) + }, + snackbarHost = { SnackbarHost(snackBarHost) }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { paddingValues -> + val aboutDialog = rememberCustomDialog { + AboutDialog(it) + } + val loadingDialog = rememberLoadingDialog() + + Column( + modifier = Modifier + .padding(paddingValues) + .nestedScroll(scrollBehavior.nestedScrollConnection) + .verticalScroll(rememberScrollState()) + ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val exportBugreportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/gzip") + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + loadingDialog.show() + context.contentResolver.openOutputStream(uri)?.use { output -> + getBugreportFile(context).inputStream().use { + it.copyTo(output) + } + } + loadingDialog.hide() + snackBarHost.showSnackbar(context.getString(R.string.log_saved)) + } + } + + // 配置卡片 + KsuIsValid { + SettingsGroupCard( + title = stringResource(R.string.configuration), + content = { + // 配置文件模板入口 + SettingItem( + icon = Icons.Filled.Fence, + title = stringResource(R.string.settings_profile_template), + summary = stringResource(R.string.settings_profile_template_summary), + onClick = { + navigator.navigate(AppProfileTemplateScreenDestination) + } + ) + + val modeItems = listOf( + stringResource(id = R.string.settings_mode_default), + stringResource(id = R.string.settings_mode_temp_enable), + stringResource(id = R.string.settings_mode_always_enable), + ) + var enhancedSecurityMode by rememberSaveable { + mutableIntStateOf( + run { + val currentEnabled = Natives.isEnhancedSecurityEnabled() + val savedPersist = prefs.getInt("enhanced_security_mode", 0) + if (savedPersist == 2) 2 else if (currentEnabled) 1 else 0 + } + ) + } + val enhancedStatus by produceState(initialValue = "") { + value = getFeatureStatus("enhanced_security") + } + val enhancedSummary = when (enhancedStatus) { + "unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary) + "managed" -> stringResource(id = R.string.feature_status_managed_summary) + else -> stringResource(id = R.string.settings_enable_enhanced_security_summary) + } + SuperDropdown( + icon = Icons.Rounded.EnhancedEncryption, + title = stringResource(id = R.string.settings_enable_enhanced_security), + summary = enhancedSummary, + items = modeItems, + leftAction = { + Icon( + Icons.Rounded.EnhancedEncryption, + modifier = Modifier.padding(end = 16.dp), + contentDescription = stringResource(id = R.string.settings_enable_enhanced_security), + tint = MaterialTheme.colorScheme.onBackground + ) + }, + enabled = enhancedStatus == "supported", + selectedIndex = enhancedSecurityMode, + onSelectedIndexChange = { index -> + when (index) { + // Default: disable and save to persist + 0 -> if (Natives.setEnhancedSecurityEnabled(false)) { + execKsud("feature save", true) + prefs.edit { putInt("enhanced_security_mode", 0) } + enhancedSecurityMode = 0 + } + + // Temporarily enable: save disabled state first, then enable + 1 -> if (Natives.setEnhancedSecurityEnabled(false)) { + execKsud("feature save", true) + if (Natives.setEnhancedSecurityEnabled(true)) { + prefs.edit { putInt("enhanced_security_mode", 0) } + enhancedSecurityMode = 1 + } + } + + // Permanently enable: enable and save + 2 -> if (Natives.setEnhancedSecurityEnabled(true)) { + execKsud("feature save", true) + prefs.edit { putInt("enhanced_security_mode", 2) } + enhancedSecurityMode = 2 + } + } + } + ) + + var suCompatMode by rememberSaveable { + mutableIntStateOf( + run { + val currentEnabled = Natives.isSuEnabled() + val savedPersist = prefs.getInt("su_compat_mode", 0) + if (savedPersist == 2) 2 else if (!currentEnabled) 1 else 0 + } + ) + } + val suStatus by produceState(initialValue = "") { + value = getFeatureStatus("su_compat") + } + val suSummary = when (suStatus) { + "unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary) + "managed" -> stringResource(id = R.string.feature_status_managed_summary) + else -> stringResource(id = R.string.settings_disable_su_summary) + } + SuperDropdown( + icon = Icons.Rounded.RemoveModerator, + title = stringResource(id = R.string.settings_disable_su), + summary = suSummary, + items = modeItems, + leftAction = { + Icon( + Icons.Rounded.RemoveModerator, + modifier = Modifier.padding(end = 16.dp), + contentDescription = stringResource(id = R.string.settings_disable_su), + tint = MaterialTheme.colorScheme.onBackground + ) + }, + enabled = suStatus == "supported", + selectedIndex = suCompatMode, + onSelectedIndexChange = { index -> + when (index) { + // Default: enable and save to persist + 0 -> if (Natives.setSuEnabled(true)) { + execKsud("feature save", true) + prefs.edit { putInt("su_compat_mode", 0) } + suCompatMode = 0 + } + + // Temporarily disable: save enabled state first, then disable + 1 -> if (Natives.setSuEnabled(true)) { + execKsud("feature save", true) + if (Natives.setSuEnabled(false)) { + prefs.edit { putInt("su_compat_mode", 0) } + suCompatMode = 1 + } + } + + // Permanently disable: disable and save + 2 -> if (Natives.setSuEnabled(false)) { + execKsud("feature save", true) + prefs.edit { putInt("su_compat_mode", 2) } + suCompatMode = 2 + } + } + } + ) + + var kernelUmountMode by rememberSaveable { + mutableIntStateOf( + run { + val currentEnabled = Natives.isKernelUmountEnabled() + val savedPersist = prefs.getInt("kernel_umount_mode", 0) + if (savedPersist == 2) 2 else if (!currentEnabled) 1 else 0 + } + ) + } + val umountStatus by produceState(initialValue = "") { + value = getFeatureStatus("kernel_umount") + } + val umountSummary = when (umountStatus) { + "unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary) + "managed" -> stringResource(id = R.string.feature_status_managed_summary) + else -> stringResource(id = R.string.settings_disable_kernel_umount_summary) + } + SuperDropdown( + icon = Icons.Rounded.RemoveCircle, + title = stringResource(id = R.string.settings_disable_kernel_umount), + summary = umountSummary, + items = modeItems, + leftAction = { + Icon( + Icons.Rounded.RemoveCircle, + modifier = Modifier.padding(end = 16.dp), + contentDescription = stringResource(id = R.string.settings_disable_kernel_umount), + tint = MaterialTheme.colorScheme.onBackground + ) + }, + enabled = umountStatus == "supported", + selectedIndex = kernelUmountMode, + onSelectedIndexChange = { index -> + when (index) { + // Default: enable and save to persist + 0 -> if (Natives.setKernelUmountEnabled(true)) { + execKsud("feature save", true) + prefs.edit { putInt("kernel_umount_mode", 0) } + kernelUmountMode = 0 + } + + // Temporarily disable: save enabled state first, then disable + 1 -> if (Natives.setKernelUmountEnabled(true)) { + execKsud("feature save", true) + if (Natives.setKernelUmountEnabled(false)) { + prefs.edit { putInt("kernel_umount_mode", 0) } + kernelUmountMode = 1 + } + } + + // Permanently disable: disable and save + 2 -> if (Natives.setKernelUmountEnabled(false)) { + execKsud("feature save", true) + prefs.edit { putInt("kernel_umount_mode", 2) } + kernelUmountMode = 2 + } + } + } + ) + + var suLogMode by rememberSaveable { + mutableIntStateOf( + run { + val currentEnabled = Natives.isSuLogEnabled() + val savedPersist = prefs.getInt("sulog_mode", 0) + if (savedPersist == 2) 2 else if (!currentEnabled) 1 else 0 + } + ) + } + val suLogStatus by produceState(initialValue = "") { + value = getFeatureStatus("sulog") + } + val suLogSummary = when (suLogStatus) { + "unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary) + "managed" -> stringResource(id = R.string.feature_status_managed_summary) + else -> stringResource(id = R.string.settings_disable_sulog_summary) + } + SuperDropdown( + title = stringResource(id = R.string.settings_disable_sulog), + summary = suLogSummary, + items = modeItems, + leftAction = { + Icon( + Icons.Rounded.RemoveCircle, + modifier = Modifier.padding(end = 16.dp), + contentDescription = stringResource(id = R.string.settings_disable_sulog), + tint = MaterialTheme.colorScheme.onBackground + ) + }, + enabled = suLogStatus == "supported", + selectedIndex = suLogMode, + onSelectedIndexChange = { index -> + when (index) { + // Default: enable and save to persist + 0 -> if (Natives.setSuLogEnabled(true)) { + execKsud("feature save", true) + prefs.edit { putInt("sulog_mode", 0) } + suLogMode = 0 + isSuLogEnabled = true + } + + // Temporarily disable: save enabled state first, then disable + 1 -> if (Natives.setSuLogEnabled(true)) { + execKsud("feature save", true) + if (Natives.setSuLogEnabled(false)) { + prefs.edit { putInt("sulog_mode", 0) } + suLogMode = 1 + isSuLogEnabled = false + } + } + + // Permanently disable: disable and save + 2 -> if (Natives.setSuLogEnabled(false)) { + execKsud("feature save", true) + prefs.edit { putInt("sulog_mode", 2) } + suLogMode = 2 + isSuLogEnabled = false + } + } + } + ) + + // 卸载模块开关 + var umountChecked by rememberSaveable { mutableStateOf(Natives.isDefaultUmountModules()) } + SwitchItem( + icon = Icons.Rounded.FolderDelete, + title = stringResource(id = R.string.settings_umount_modules_default), + summary = stringResource(id = R.string.settings_umount_modules_default_summary), + checked = umountChecked, + onCheckedChange = { + if (Natives.setDefaultUmountModules(it)) { + umountChecked = it + } + } + ) + } + ) + } + + // 应用设置卡片 + SettingsGroupCard( + title = stringResource(R.string.app_settings), + content = { + // 更新检查开关 + var checkUpdate by rememberSaveable { + mutableStateOf(prefs.getBoolean("check_update", true)) + } + SwitchItem( + icon = Icons.Filled.Update, + title = stringResource(R.string.settings_check_update), + summary = stringResource(R.string.settings_check_update_summary), + checked = checkUpdate, + onCheckedChange = { enabled -> + prefs.edit { putBoolean("check_update", enabled) } + checkUpdate = enabled + } + ) + + // WebUI引擎选择 + KsuIsValid { + WebUIEngineSelector( + selectedEngine = selectedEngine, + onEngineSelected = { engine -> + selectedEngine = engine + prefs.edit { putString("webui_engine", engine) } + } + ) + } + + // Web调试和Web X Eruda 开关 + var enableWebDebugging by rememberSaveable { + mutableStateOf(prefs.getBoolean("enable_web_debugging", false)) + } + var useWebUIXEruda by rememberSaveable { + mutableStateOf(prefs.getBoolean("use_webuix_eruda", false)) + } + + KsuIsValid { + SwitchItem( + icon = Icons.Filled.DeveloperMode, + title = stringResource(R.string.enable_web_debugging), + summary = stringResource(R.string.enable_web_debugging_summary), + checked = enableWebDebugging, + onCheckedChange = { enabled -> + prefs.edit { putBoolean("enable_web_debugging", enabled) } + enableWebDebugging = enabled + } + ) + + AnimatedVisibility( + visible = enableWebDebugging && selectedEngine == "wx", + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + SwitchItem( + icon = Icons.Filled.FormatListNumbered, + title = stringResource(R.string.use_webuix_eruda), + summary = stringResource(R.string.use_webuix_eruda_summary), + checked = useWebUIXEruda, + onCheckedChange = { enabled -> + prefs.edit { putBoolean("use_webuix_eruda", enabled) } + useWebUIXEruda = enabled + } + ) + } + } + + // 更多设置 + SettingItem( + icon = Icons.Filled.Settings, + title = stringResource(R.string.more_settings), + summary = stringResource(R.string.more_settings), + onClick = { + navigator.navigate(MoreSettingsScreenDestination) + } + ) + } + ) + + // 工具卡片 + SettingsGroupCard( + title = stringResource(R.string.tools), + content = { + var showBottomsheet by remember { mutableStateOf(false) } + + SettingItem( + icon = Icons.Filled.BugReport, + title = stringResource(R.string.send_log), + onClick = { + showBottomsheet = true + } + ) + + // 查看使用日志 + KsuIsValid { + if (isSuLogEnabled) { + SettingItem( + icon = Icons.Filled.Visibility, + title = stringResource(R.string.log_viewer_view_logs), + summary = stringResource(R.string.log_viewer_view_logs_summary), + onClick = { + navigator.navigate(LogViewerScreenDestination) + } + ) + } + } + val lkmMode = Natives.isLkmMode + KsuIsValid { + if (lkmMode) { + SettingItem( + icon = Icons.Filled.FolderOff, + title = stringResource(R.string.umount_path_manager), + summary = stringResource(R.string.umount_path_manager_summary), + onClick = { + navigator.navigate(UmountManagerScreenDestination) + } + ) + } + } + + if (showBottomsheet) { + LogBottomSheet( + onDismiss = { showBottomsheet = false }, + onSaveLog = { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm") + val current = LocalDateTime.now().format(formatter) + exportBugreportLauncher.launch("KernelSU_bugreport_${current}.tar.gz") + showBottomsheet = false + }, + onShareLog = { + scope.launch { + val bugreport = loadingDialog.withLoading { + withContext(Dispatchers.IO) { + getBugreportFile(context) + } + } + + val uri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.fileprovider", + bugreport + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + setDataAndType(uri, "application/gzip") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.send_log) + ) + ) + + showBottomsheet = false + } + } + ) + } + if (lkmMode) { + UninstallItem(navigator) { + loadingDialog.withLoading(it) + } + } + } + ) + + // 关于卡片 + SettingsGroupCard( + title = stringResource(R.string.about), + content = { + SettingItem( + icon = Icons.Filled.Info, + title = stringResource(R.string.about), + onClick = { + aboutDialog.show() + } + ) + } + ) + + Spacer(modifier = Modifier.height(SPACING_LARGE)) + } + } +} + +@Composable +private fun SettingsGroupCard( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow), + elevation = getCardElevation() + ) { + Column( + modifier = Modifier.padding(vertical = SPACING_MEDIUM) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + color = MaterialTheme.colorScheme.primary + ) + content() + } + } +} + +@Composable +private fun WebUIEngineSelector( + selectedEngine: String, + onEngineSelected: (String) -> Unit +) { + var showDialog by remember { mutableStateOf(false) } + val engineOptions = listOf( + "default" to stringResource(R.string.engine_auto_select), + "wx" to stringResource(R.string.engine_force_webuix), + "ksu" to stringResource(R.string.engine_force_ksu) + ) + + SettingItem( + icon = Icons.Filled.WebAsset, + title = stringResource(R.string.use_webuix), + summary = engineOptions.find { it.first == selectedEngine }?.second + ?: stringResource(R.string.engine_auto_select), + onClick = { showDialog = true } + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(stringResource(R.string.use_webuix)) }, + text = { + Column { + engineOptions.forEach { (value, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onEngineSelected(value) + showDialog = false + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedEngine == value, + onClick = null + ) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(text = label) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogBottomSheet( + onDismiss: () -> Unit, + onSaveLog: () -> Unit, + onShareLog: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + LogActionButton( + icon = Icons.Filled.Save, + text = stringResource(R.string.save_log), + onClick = onSaveLog + ) + + LogActionButton( + icon = Icons.Filled.Share, + text = stringResource(R.string.send_log), + onClick = onShareLog + ) + } + Spacer(modifier = Modifier.height(SPACING_LARGE)) + } +} + +@Composable +fun LogActionButton( + icon: ImageVector, + text: String, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable(onClick = onClick) + .padding(SPACING_MEDIUM) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + Icon( + imageVector = icon, + contentDescription = text, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +fun SettingItem( + icon: ImageVector, + title: String, + summary: String? = null, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = SPACING_LARGE, vertical = 12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(end = SPACING_LARGE) + .size(24.dp) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + if (summary != null) { + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium + ) + } + } + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } +} + +@Composable +fun SwitchItem( + icon: ImageVector, + title: String, + summary: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = SPACING_LARGE, vertical = 12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(end = SPACING_LARGE) + .size(24.dp) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + if (summary != null) { + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium + ) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +@Composable +fun UninstallItem( + navigator: DestinationsNavigator, + withLoading: suspend (suspend () -> Unit) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val uninstallConfirmDialog = rememberConfirmDialog() + val showTodo = { + Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() + } + val uninstallDialog = rememberUninstallDialog { uninstallType -> + scope.launch { + val result = uninstallConfirmDialog.awaitConfirm( + title = context.getString(uninstallType.title), + content = context.getString(uninstallType.message) + ) + if (result == ConfirmResult.Confirmed) { + withLoading { + when (uninstallType) { + UninstallType.TEMPORARY -> showTodo() + UninstallType.PERMANENT -> navigator.navigate( + FlashScreenDestination(FlashIt.FlashUninstall) + ) + UninstallType.RESTORE_STOCK_IMAGE -> navigator.navigate( + FlashScreenDestination(FlashIt.FlashRestore) + ) + UninstallType.NONE -> Unit + } + } + } + } + } + + SettingItem( + icon = Icons.Filled.Delete, + title = stringResource(id = R.string.settings_uninstall), + onClick = { + uninstallDialog.show() + } + ) +} + +enum class UninstallType(val title: Int, val message: Int, val icon: ImageVector) { + TEMPORARY( + R.string.settings_uninstall_temporary, + R.string.settings_uninstall_temporary_message, + Icons.Filled.Delete + ), + PERMANENT( + R.string.settings_uninstall_permanent, + R.string.settings_uninstall_permanent_message, + Icons.Filled.DeleteForever + ), + RESTORE_STOCK_IMAGE( + R.string.settings_restore_stock_image, + R.string.settings_restore_stock_image_message, + Icons.AutoMirrored.Filled.Undo + ), + NONE(0, 0, Icons.Filled.Delete) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rememberUninstallDialog(onSelected: (UninstallType) -> Unit): DialogHandle { + return rememberCustomDialog { dismiss -> + val options = listOf( + UninstallType.PERMANENT, + UninstallType.RESTORE_STOCK_IMAGE + ) + val listOptions = options.map { + ListOption( + titleText = stringResource(it.title), + subtitleText = if (it.message != 0) stringResource(it.message) else null, + icon = IconSource(it.icon) + ) + } + + var selectedOption by remember { mutableStateOf(null) } + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + AlertDialog( + onDismissRequest = { + dismiss() + }, + title = { + Text( + text = stringResource(R.string.settings_uninstall), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column( + modifier = Modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + options.forEachIndexed { index, option -> + val isSelected = selectedOption == option + val backgroundColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + Color.Transparent + val contentColor = if (isSelected) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurface + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(backgroundColor) + .clickable { + selectedOption = option + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = option.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + ) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = listOptions[index].titleText, + style = MaterialTheme.typography.titleMedium, + ) + listOptions[index].subtitleText?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) + contentColor.copy(alpha = 0.8f) + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (isSelected) { + Icon( + imageVector = Icons.Default.RadioButtonChecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } else { + Icon( + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + selectedOption?.let { onSelected(it) } + dismiss() + }, + enabled = selectedOption != null, + ) { + Text( + text = stringResource(android.R.string.ok) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + dismiss() + } + ) { + Text( + text = stringResource(android.R.string.cancel), + ) + } + }, + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 4.dp + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + TopAppBar( + title = { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt new file mode 100644 index 0000000..d38d211 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt @@ -0,0 +1,961 @@ +package com.sukisu.ultra.ui.screen + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.* +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.dergoogler.mmrl.ui.component.LabelItem +import com.dergoogler.mmrl.ui.component.LabelItemDefaults +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.AppProfileScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.FabMenuPresets +import com.sukisu.ultra.ui.component.SearchAppBar +import com.sukisu.ultra.ui.component.VerticalExpandableFab +import com.sukisu.ultra.ui.util.module.ModuleModify +import com.sukisu.ultra.ui.viewmodel.AppCategory +import com.sukisu.ultra.ui.viewmodel.SortType +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +enum class AppPriority(val value: Int) { + ROOT(1), CUSTOM(2), DEFAULT(3) +} + +data class BottomSheetMenuItem( + val icon: ImageVector, + val titleRes: Int, + val onClick: () -> Unit +) + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Destination +@Composable +fun SuperUserScreen(navigator: DestinationsNavigator) { + val viewModel = viewModel() + val scope = rememberCoroutineScope() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val listState = rememberLazyListState() + val context = LocalContext.current + val snackBarHostState = remember { SnackbarHostState() } + + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by remember { mutableStateOf(false) } + + val backupLauncher = ModuleModify.rememberAllowlistBackupLauncher(context, snackBarHostState) + val restoreLauncher = ModuleModify.rememberAllowlistRestoreLauncher(context, snackBarHostState) + + LaunchedEffect(navigator) { + viewModel.search = "" + } + + LaunchedEffect(viewModel.selectedApps, viewModel.showBatchActions) { + if (viewModel.showBatchActions && viewModel.selectedApps.isEmpty()) { + viewModel.showBatchActions = false + } + } + + val filteredAndSortedAppGroups = remember( + viewModel.appGroupList, + viewModel.selectedCategory, + viewModel.currentSortType, + viewModel.search, + viewModel.showSystemApps + ) { + var groups = viewModel.appGroupList + + // 按分类筛选 + groups = when (viewModel.selectedCategory) { + AppCategory.ALL -> groups + AppCategory.ROOT -> groups.filter { it.allowSu } + AppCategory.CUSTOM -> groups.filter { !it.allowSu && it.hasCustomProfile } + AppCategory.DEFAULT -> groups.filter { !it.allowSu && !it.hasCustomProfile } + } + + // 排序 + groups.sortedWith { group1, group2 -> + val priority1 = when { + group1.allowSu -> AppPriority.ROOT + group1.hasCustomProfile -> AppPriority.CUSTOM + else -> AppPriority.DEFAULT + } + val priority2 = when { + group2.allowSu -> AppPriority.ROOT + group2.hasCustomProfile -> AppPriority.CUSTOM + else -> AppPriority.DEFAULT + } + + val priorityComparison = priority1.value.compareTo(priority2.value) + if (priorityComparison != 0) { + priorityComparison + } else { + when (viewModel.currentSortType) { + SortType.NAME_ASC -> group1.mainApp.label.lowercase() + .compareTo(group2.mainApp.label.lowercase()) + SortType.NAME_DESC -> group2.mainApp.label.lowercase() + .compareTo(group1.mainApp.label.lowercase()) + SortType.INSTALL_TIME_NEW -> group2.mainApp.packageInfo.firstInstallTime + .compareTo(group1.mainApp.packageInfo.firstInstallTime) + SortType.INSTALL_TIME_OLD -> group1.mainApp.packageInfo.firstInstallTime + .compareTo(group2.mainApp.packageInfo.firstInstallTime) + else -> group1.mainApp.label.lowercase() + .compareTo(group2.mainApp.label.lowercase()) + } + } + } + } + + val appCounts = remember(viewModel.appGroupList, viewModel.showSystemApps) { + mapOf( + AppCategory.ALL to viewModel.appGroupList.size, + AppCategory.ROOT to viewModel.appGroupList.count { it.allowSu }, + AppCategory.CUSTOM to viewModel.appGroupList.count { !it.allowSu && it.hasCustomProfile }, + AppCategory.DEFAULT to viewModel.appGroupList.count { !it.allowSu && !it.hasCustomProfile } + ) + } + + Scaffold( + topBar = { + SearchAppBar( + title = { TopBarTitle(viewModel.selectedCategory, appCounts) }, + searchText = viewModel.search, + onSearchTextChange = { viewModel.search = it }, + onClearClick = { viewModel.search = "" }, + dropdownContent = { + IconButton(onClick = { showBottomSheet = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(id = R.string.settings), + ) + } + }, + scrollBehavior = scrollBehavior + ) + }, + snackbarHost = { SnackbarHost(snackBarHostState) }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + floatingActionButton = { + SuperUserFab(viewModel, filteredAndSortedAppGroups, listState, scope) + } + ) { innerPadding -> + SuperUserContent( + innerPadding = innerPadding, + viewModel = viewModel, + filteredAndSortedAppGroups = filteredAndSortedAppGroups, + listState = listState, + scrollBehavior = scrollBehavior, + navigator = navigator, + scope = scope + ) + + if (showBottomSheet) { + SuperUserBottomSheet( + bottomSheetState = bottomSheetState, + onDismiss = { showBottomSheet = false }, + viewModel = viewModel, + appCounts = appCounts, + backupLauncher = backupLauncher, + restoreLauncher = restoreLauncher, + scope = scope, + listState = listState + ) + } + } +} + +@Composable +private fun TopBarTitle( + selectedCategory: AppCategory, + appCounts: Map +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(R.string.superuser)) + + if (selectedCategory != AppCategory.ALL) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(start = 4.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(selectedCategory.displayNameRes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "(${appCounts[selectedCategory] ?: 0})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } +} + +@Composable +private fun SuperUserFab( + viewModel: SuperUserViewModel, + filteredAndSortedAppGroups: List, + listState: androidx.compose.foundation.lazy.LazyListState, + scope: CoroutineScope +) { + VerticalExpandableFab( + menuItems = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) { + FabMenuPresets.getBatchActionMenuItems( + onCancel = { + viewModel.selectedApps = emptySet() + viewModel.showBatchActions = false + }, + onDeny = { scope.launch { viewModel.updateBatchPermissions(false) } }, + onAllow = { scope.launch { viewModel.updateBatchPermissions(true) } }, + onUnmountModules = { + scope.launch { viewModel.updateBatchPermissions( + allowSu = false, + umountModules = true + ) } + }, + onDisableUnmount = { + scope.launch { viewModel.updateBatchPermissions( + allowSu = false, + umountModules = false + ) } + } + ) + } else { + FabMenuPresets.getScrollMenuItems( + onScrollToTop = { scope.launch { listState.animateScrollToItem(0) } }, + onScrollToBottom = { + scope.launch { + val lastIndex = filteredAndSortedAppGroups.size - 1 + if (lastIndex >= 0) listState.animateScrollToItem(lastIndex) + } + } + ) + }, + mainButtonIcon = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) { + Icons.Filled.GridView + } else { + Icons.Filled.Add + }, + mainButtonExpandedIcon = Icons.Filled.Close + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SuperUserContent( + innerPadding: PaddingValues, + viewModel: SuperUserViewModel, + filteredAndSortedAppGroups: List, + listState: androidx.compose.foundation.lazy.LazyListState, + scrollBehavior: TopAppBarScrollBehavior, + navigator: DestinationsNavigator, + scope: CoroutineScope +) { + val expandedGroups = remember { mutableStateOf(setOf()) } + val density = LocalDensity.current + val targetSizePx = remember(density) { with(density) { 36.dp.roundToPx() } } + val context = LocalContext.current + + PullToRefreshBox( + modifier = Modifier.padding(innerPadding), + onRefresh = { scope.launch { viewModel.fetchAppList() } }, + isRefreshing = viewModel.isRefreshing + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + filteredAndSortedAppGroups.forEachIndexed { _, appGroup -> + item(key = "${appGroup.uid}-${appGroup.mainApp.packageName}") { + AppGroupItem( + expandedGroups = expandedGroups, + appGroup = appGroup, + isSelected = appGroup.packageNames.any { viewModel.selectedApps.contains(it) }, + onToggleSelection = { + appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) } + }, + onClick = { + if (viewModel.showBatchActions) { + appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) } + } else if (appGroup.apps.size > 1) { + expandedGroups.value = if (expandedGroups.value.contains(appGroup.uid)) { + expandedGroups.value - appGroup.uid + } else { + expandedGroups.value + appGroup.uid + } + } else { + navigator.navigate(AppProfileScreenDestination(appGroup.mainApp)) + } + }, + onLongClick = { + if (!viewModel.showBatchActions) { + viewModel.toggleBatchMode() + appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) } + } + }, + viewModel = viewModel + ) + } + + if (appGroup.apps.size <= 1) return@forEachIndexed + + items(appGroup.apps, key = { "${it.packageName}-${it.uid}" }) { app -> + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(app.packageInfo) + .size(targetSizePx) + .crossfade(true) + .build() + ) + + val listItemContent = remember(app.packageName, appGroup.uid) { + @Composable { + ListItem( + modifier = Modifier + .clickable { navigator.navigate(AppProfileScreenDestination(app)) } + .fillMaxWidth() + .padding(start = 10.dp), + headlineContent = { Text(app.label, style = MaterialTheme.typography.bodyMedium) }, + supportingContent = { Text(app.packageName, style = MaterialTheme.typography.bodySmall) }, + leadingContent = { + Image( + painter = painter, + contentDescription = app.label, + modifier = Modifier + .padding(4.dp) + .size(36.dp), + contentScale = ContentScale.Crop + ) + } + ) + } + } + + AnimatedVisibility( + visible = expandedGroups.value.contains(appGroup.uid), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + listItemContent() + } + } + } + + if (filteredAndSortedAppGroups.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().height(400.dp), + contentAlignment = Alignment.Center + ) { + if ((viewModel.isRefreshing || viewModel.appGroupList.isEmpty()) && viewModel.search.isEmpty()) { + LoadingAnimation(isLoading = true) + } else { + EmptyState( + selectedCategory = viewModel.selectedCategory, + isSearchEmpty = viewModel.search.isNotEmpty() + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SuperUserBottomSheet( + bottomSheetState: SheetState, + onDismiss: () -> Unit, + viewModel: SuperUserViewModel, + appCounts: Map, + backupLauncher: androidx.activity.result.ActivityResultLauncher, + restoreLauncher: androidx.activity.result.ActivityResultLauncher, + scope: CoroutineScope, + listState: androidx.compose.foundation.lazy.LazyListState +) { + val bottomSheetMenuItems = remember(viewModel.showSystemApps) { + listOf( + BottomSheetMenuItem( + icon = Icons.Filled.Refresh, + titleRes = R.string.refresh, + onClick = { + scope.launch { + viewModel.fetchAppList() + bottomSheetState.hide() + onDismiss() + } + } + ), + BottomSheetMenuItem( + icon = if (viewModel.showSystemApps) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + titleRes = if (viewModel.showSystemApps) R.string.hide_system_apps else R.string.show_system_apps, + onClick = { + viewModel.updateShowSystemApps(!viewModel.showSystemApps) + scope.launch { + kotlinx.coroutines.delay(100) + bottomSheetState.hide() + onDismiss() + } + } + ), + BottomSheetMenuItem( + icon = Icons.Filled.Save, + titleRes = R.string.backup_allowlist, + onClick = { + backupLauncher.launch(ModuleModify.createAllowlistBackupIntent()) + scope.launch { + bottomSheetState.hide() + onDismiss() + } + } + ), + BottomSheetMenuItem( + icon = Icons.Filled.RestoreFromTrash, + titleRes = R.string.restore_allowlist, + onClick = { + restoreLauncher.launch(ModuleModify.createAllowlistRestoreIntent()) + scope.launch { + bottomSheetState.hide() + onDismiss() + } + } + ) + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { + Surface( + modifier = Modifier.padding(vertical = 11.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp) + ) { + Box(Modifier.size(width = 32.dp, height = 4.dp)) + } + } + ) { + BottomSheetContent( + menuItems = bottomSheetMenuItems, + currentSortType = viewModel.currentSortType, + onSortTypeChanged = { newSortType -> + viewModel.updateCurrentSortType(newSortType) + scope.launch { + bottomSheetState.hide() + onDismiss() + } + }, + selectedCategory = viewModel.selectedCategory, + onCategorySelected = { newCategory -> + viewModel.updateSelectedCategory(newCategory) + scope.launch { + listState.animateScrollToItem(0) + bottomSheetState.hide() + onDismiss() + } + }, + appCounts = appCounts + ) + } +} + +@Composable +private fun BottomSheetContent( + menuItems: List, + currentSortType: SortType, + onSortTypeChanged: (SortType) -> Unit, + selectedCategory: AppCategory, + onCategorySelected: (AppCategory) -> Unit, + appCounts: Map +) { + Column( + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp) + ) { + Text( + text = stringResource(R.string.menu_options), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(4), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(menuItems) { menuItem -> + BottomSheetMenuItemView(menuItem = menuItem) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp)) + + Text( + text = stringResource(R.string.sort_options), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(SortType.entries.toTypedArray()) { sortType -> + FilterChip( + onClick = { onSortTypeChanged(sortType) }, + label = { Text(stringResource(sortType.displayNameRes)) }, + selected = currentSortType == sortType + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp)) + + Text( + text = stringResource(R.string.app_categories), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(AppCategory.entries.toTypedArray()) { category -> + CategoryChip( + category = category, + isSelected = selectedCategory == category, + onClick = { onCategorySelected(category) }, + appCount = appCounts[category] ?: 0 + ) + } + } + } +} + +@Composable +private fun CategoryChip( + category: AppCategory, + isSelected: Boolean, + onClick: () -> Unit, + appCount: Int, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1.0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "categoryChipScale" + ) + + Surface( + modifier = modifier + .fillMaxWidth() + .scale(scale) + .clickable(interactionSource = interactionSource, indication = null) { onClick() }, + shape = RoundedCornerShape(12.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + tonalElevation = if (isSelected) 4.dp else 0.dp + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(category.displayNameRes), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ), + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + AnimatedVisibility( + visible = isSelected, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.selected), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(16.dp) + ) + } + } + + Text( + text = "$appCount apps", + style = MaterialTheme.typography.labelSmall, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } +} + +@Composable +private fun BottomSheetMenuItemView(menuItem: BottomSheetMenuItem) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1.0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "menuItemScale" + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .clickable(interactionSource = interactionSource, indication = null) { menuItem.onClick() } + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = menuItem.icon, + contentDescription = stringResource(menuItem.titleRes), + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(menuItem.titleRes), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2 + ) + } +} + +@Composable +private fun LoadingAnimation( + modifier: Modifier = Modifier, + isLoading: Boolean = true +) { + val infiniteTransition = rememberInfiniteTransition(label = "loading") + + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(600, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + AnimatedVisibility( + visible = isLoading, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = modifier + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + LinearProgressIndicator( + modifier = Modifier.width(200.dp).height(4.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = alpha), + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) + } + } +} + +@Composable +@SuppressLint("ModifierParameter") +private fun EmptyState( + selectedCategory: AppCategory, + modifier: Modifier = Modifier, + isSearchEmpty: Boolean = false +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + ) { + Icon( + imageVector = if (isSearchEmpty) Icons.Filled.SearchOff else Icons.Filled.Archive, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier.size(96.dp).padding(bottom = 16.dp) + ) + Text( + text = if (isSearchEmpty || selectedCategory == AppCategory.ALL) { + stringResource(R.string.no_apps_found) + } else { + stringResource(R.string.no_apps_in_category) + }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun AppGroupItem( + appGroup: SuperUserViewModel.AppGroup, + isSelected: Boolean, + onToggleSelection: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewModel: SuperUserViewModel, + expandedGroups: MutableState> +) { + val mainApp = appGroup.mainApp + + ListItem( + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { onLongClick() }, + onTap = { onClick() } + ) + }, + headlineContent = { + Text(mainApp.label) + }, + supportingContent = { + Column { + val summaryText = if (appGroup.apps.size > 1) { + stringResource(R.string.group_contains_apps, appGroup.apps.size) + } else { + mainApp.packageName + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(summaryText) + + if (appGroup.apps.size > 1) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.rotate( + animateFloatAsState( + targetValue = if (expandedGroups.value.contains(appGroup.uid)) 180f else 0f, + animationSpec = tween(200, easing = LinearOutSlowInEasing), + label = "" + ).value + ) + ) + } + } + + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (appGroup.allowSu) { + LabelItem(text = "ROOT") + } else { + if (Natives.uidShouldUmount(appGroup.uid)) { + LabelItem( + text = "UMOUNT", + style = LabelItemDefaults.style.copy( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + } + } + if (appGroup.hasCustomProfile) { + LabelItem( + text = "CUSTOM", + style = LabelItemDefaults.style.copy( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) + ) + } else if (!appGroup.allowSu) { + LabelItem( + text = "DEFAULT", + style = LabelItemDefaults.style.copy( + containerColor = Color.Gray + ) + ) + } + if (appGroup.apps.size > 1) { + appGroup.userName?.let { + LabelItem( + text = it, + style = LabelItemDefaults.style.copy( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) + } + } + } + } + }, + leadingContent = { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(mainApp.packageInfo) + .crossfade(true) + .build(), + contentDescription = mainApp.label, + modifier = Modifier.padding(4.dp).width(48.dp).height(48.dp) + ) + }, + trailingContent = { + AnimatedVisibility( + visible = viewModel.showBatchActions, + enter = fadeIn(animationSpec = tween(200)) + scaleIn( + animationSpec = tween(200), + initialScale = 0.6f + ), + exit = fadeOut(animationSpec = tween(200)) + scaleOut( + animationSpec = tween(200), + targetScale = 0.6f + ) + ) { + val checkboxInteractionSource = remember { MutableInteractionSource() } + val isCheckboxPressed by checkboxInteractionSource.collectIsPressedAsState() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + AnimatedVisibility( + visible = isCheckboxPressed, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut() + ) { + Text( + text = if (isSelected) stringResource(R.string.selected) else stringResource(R.string.select), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(end = 4.dp) + ) + } + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + interactionSource = checkboxInteractionSource, + ) + } + } + } + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt new file mode 100644 index 0000000..6aa1def --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt @@ -0,0 +1,282 @@ +package com.sukisu.ultra.ui.screen + +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.getSystemService +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.result.getOr +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.viewmodel.TemplateViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * @author weishu + * @date 2023/10/20. + */ + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Destination +@Composable +fun AppProfileTemplateScreen( + navigator: DestinationsNavigator, + resultRecipient: ResultRecipient +) { + val viewModel = viewModel() + val scope = rememberCoroutineScope() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + LaunchedEffect(Unit) { + if (viewModel.templateList.isEmpty()) { + viewModel.fetchTemplates() + } + } + + // handle result from TemplateEditorScreen, refresh if needed + resultRecipient.onNavResult { result -> + if (result.getOr { false }) { + scope.launch { viewModel.fetchTemplates() } + } + } + + Scaffold( + topBar = { + val context = LocalContext.current + val clipboardManager = context.getSystemService() + val showToast = fun(msg: String) { + scope.launch(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + TopBar( + onBack = dropUnlessResumed { navigator.popBackStack() }, + onSync = { + scope.launch { viewModel.fetchTemplates(true) } + }, + onImport = { + scope.launch { + val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() + if (clipboardText.isNullOrEmpty()) { + showToast(context.getString(R.string.app_profile_template_import_empty)) + return@launch + } + viewModel.importTemplates( + clipboardText, + { + showToast(context.getString(R.string.app_profile_template_import_success)) + viewModel.fetchTemplates(false) + }, + showToast + ) + } + }, + onExport = { + scope.launch { + viewModel.exportTemplates( + { + showToast(context.getString(R.string.app_profile_template_export_empty)) + } + ) { text -> + clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text)) + } + } + }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + navigator.navigate( + TemplateEditorScreenDestination( + TemplateViewModel.TemplateInfo(), + false + ) + ) + }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text(stringResource(id = R.string.app_profile_template_create)) }, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { innerPadding -> + PullToRefreshBox( + modifier = Modifier.padding(innerPadding), + isRefreshing = viewModel.isRefreshing, + onRefresh = { + scope.launch { viewModel.fetchTemplates() } + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = remember { + PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */) + } + ) { + items(viewModel.templateList, key = { it.id }) { app -> + TemplateItem(navigator, app) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TemplateItem( + navigator: DestinationsNavigator, + template: TemplateViewModel.TemplateInfo +) { + ListItem( + modifier = Modifier + .clickable { + navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) + }, + headlineContent = { Text(template.name) }, + supportingContent = { + Column { + Text( + text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}", + style = MaterialTheme.typography.bodySmall, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + ) + Text(template.description) + FlowRow { + LabelText(label = "UID: ${template.uid}") + LabelText(label = "GID: ${template.gid}") + LabelText(label = template.context) + if (template.local) { + LabelText(label = "local") + } else { + LabelText(label = "remote") + } + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + onBack: () -> Unit, + onSync: () -> Unit = {}, + onImport: () -> Unit = {}, + onExport: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + val cardAlpha = CardConfig.cardAlpha + + TopAppBar( + title = { + Text(stringResource(R.string.settings_profile_template)) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + navigationIcon = { + IconButton( + onClick = onBack + ) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } + }, + actions = { + IconButton(onClick = onSync) { + Icon( + Icons.Filled.Sync, + contentDescription = stringResource(id = R.string.app_profile_template_sync) + ) + } + + var showDropdown by remember { mutableStateOf(false) } + IconButton(onClick = { + showDropdown = true + }) { + Icon( + imageVector = Icons.Filled.ImportExport, + contentDescription = stringResource(id = R.string.app_profile_import_export) + ) + + DropdownMenu(expanded = showDropdown, onDismissRequest = { + showDropdown = false + }) { + DropdownMenuItem(text = { + Text(stringResource(id = R.string.app_profile_import_from_clipboard)) + }, onClick = { + onImport() + showDropdown = false + }) + DropdownMenuItem(text = { + Text(stringResource(id = R.string.app_profile_export_to_clipboard)) + }, onClick = { + onExport() + showDropdown = false + }) + } + } + }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) +} + +@Composable +fun LabelText(label: String) { + Box( + modifier = Modifier + .padding(top = 4.dp, end = 4.dp) + .background( + Color.Black, + shape = RoundedCornerShape(4.dp) + ) + ) { + Text( + text = label, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 5.dp), + style = TextStyle( + fontSize = 8.sp, + color = Color.White, + ) + ) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt new file mode 100644 index 0000000..89e3e7b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt @@ -0,0 +1,319 @@ +package com.sukisu.ultra.ui.screen + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.profile.RootProfileConfig +import com.sukisu.ultra.ui.util.deleteAppProfileTemplate +import com.sukisu.ultra.ui.util.getAppProfileTemplate +import com.sukisu.ultra.ui.util.setAppProfileTemplate +import com.sukisu.ultra.ui.viewmodel.TemplateViewModel +import com.sukisu.ultra.ui.viewmodel.toJSON + +/** + * @author weishu + * @date 2023/10/20. + */ +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@Destination +@Composable +fun TemplateEditorScreen( + navigator: ResultBackNavigator, + initialTemplate: TemplateViewModel.TemplateInfo, + readOnly: Boolean = true, +) { + + val isCreation = initialTemplate.id.isBlank() + val autoSave = !isCreation + + var template by rememberSaveable { + mutableStateOf(initialTemplate) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + BackHandler { + navigator.navigateBack(result = !readOnly) + } + + Scaffold( + topBar = { + val author = + if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else "" + val readOnlyHint = if (readOnly) { + " - ${stringResource(id = R.string.app_profile_template_readonly)}" + } else { + "" + } + val titleSummary = "${initialTemplate.id}$author$readOnlyHint" + val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed) + val context = LocalContext.current + + TopBar( + title = if (isCreation) { + stringResource(R.string.app_profile_template_create) + } else if (readOnly) { + stringResource(R.string.app_profile_template_view) + } else { + stringResource(R.string.app_profile_template_edit) + }, + readOnly = readOnly, + summary = titleSummary, + onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) }, + onDelete = { + if (deleteAppProfileTemplate(template.id)) { + navigator.navigateBack(result = true) + } + }, + onSave = { + if (saveTemplate(template, isCreation)) { + navigator.navigateBack(result = true) + } else { + Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show() + } + }, + scrollBehavior = scrollBehavior + ) + }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .nestedScroll(scrollBehavior.nestedScrollConnection) + .verticalScroll(rememberScrollState()) + .pointerInteropFilter { + // disable click and ripple if readOnly + readOnly + } + ) { + if (isCreation) { + var errorHint by remember { + mutableStateOf("") + } + val idConflictError = stringResource(id = R.string.app_profile_template_id_exist) + val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid) + TextEdit( + label = stringResource(id = R.string.app_profile_template_id), + text = template.id, + errorHint = errorHint, + isError = errorHint.isNotEmpty() + ) { value -> + errorHint = if (isTemplateExist(value)) { + idConflictError + } else if (!isValidTemplateId(value)) { + idInvalidError + } else { + "" + } + template = template.copy(id = value) + } + } + + TextEdit( + label = stringResource(id = R.string.app_profile_template_name), + text = template.name + ) { value -> + template.copy(name = value).run { + if (autoSave) { + if (!saveTemplate(this)) { + // failed + return@run + } + } + template = this + } + } + TextEdit( + label = stringResource(id = R.string.app_profile_template_description), + text = template.description + ) { value -> + template.copy(description = value).run { + if (autoSave) { + if (!saveTemplate(this)) { + // failed + return@run + } + } + template = this + } + } + + RootProfileConfig(fixedName = true, + profile = toNativeProfile(template), + onProfileChange = { + template.copy( + uid = it.uid, + gid = it.gid, + groups = it.groups, + capabilities = it.capabilities, + context = it.context, + namespace = it.namespace, + rules = it.rules.split("\n") + ).run { + if (autoSave) { + if (!saveTemplate(this)) { + // failed + return@run + } + } + template = this + } + }) + } + } +} + +fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile { + return Natives.Profile().copy(rootTemplate = templateInfo.id, + uid = templateInfo.uid, + gid = templateInfo.gid, + groups = templateInfo.groups, + capabilities = templateInfo.capabilities, + context = templateInfo.context, + namespace = templateInfo.namespace, + rules = templateInfo.rules.joinToString("\n").ifBlank { "" }) +} + +fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean { + if (template.id.isBlank()) { + return false + } + + if (!isValidTemplateId(template.id)) { + return false + } + + return true +} + +fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean { + if (!isTemplateValid(template)) { + return false + } + + if (isCreation && isTemplateExist(template.id)) { + return false + } + + val json = template.toJSON() + json.put("local", true) + return setAppProfileTemplate(template.id, json.toString()) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + title: String, + readOnly: Boolean, + summary: String = "", + onBack: () -> Unit, + onDelete: () -> Unit = {}, + onSave: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + TopAppBar( + title = { + Column { + Text(title) + if (summary.isNotBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + }, navigationIcon = { + IconButton( + onClick = onBack + ) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } + }, actions = { + if (readOnly) { + return@TopAppBar + } + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.DeleteForever, + contentDescription = stringResource(id = R.string.app_profile_template_delete) + ) + } + IconButton(onClick = onSave) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(id = R.string.app_profile_template_save) + ) + } + }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) +} + +@Composable +private fun TextEdit( + label: String, + text: String, + errorHint: String = "", + isError: Boolean = false, + onValueChange: (String) -> Unit = {} +) { + ListItem(headlineContent = { + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = text, + modifier = Modifier.fillMaxWidth(), + label = { Text(label) }, + suffix = { + if (errorHint.isNotBlank()) { + Text( + text = if (isError) errorHint else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + }, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + }), + onValueChange = onValueChange + ) + }) +} + +private fun isValidTemplateId(id: String): Boolean { + return Regex("""^([A-Za-z][A-Za-z\d_]*\.)*[A-Za-z][A-Za-z\d_]*$""").matches(id) +} + +private fun isTemplateExist(id: String): Boolean { + return getAppProfileTemplate(id).isNotBlank() +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/UmountManagerScreen.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/UmountManagerScreen.kt new file mode 100644 index 0000000..a93de1d --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/UmountManagerScreen.kt @@ -0,0 +1,410 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.component.ConfirmResult +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SPACING_SMALL = 3.dp +private val SPACING_MEDIUM = 8.dp +private val SPACING_LARGE = 16.dp + +data class UmountPathEntry( + val path: String, + val flags: Int, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun UmountManagerScreen(navigator: DestinationsNavigator) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val snackBarHost = LocalSnackbarHost.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val confirmDialog = rememberConfirmDialog() + + var pathList by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var showAddDialog by remember { mutableStateOf(false) } + + fun loadPaths() { + scope.launch(Dispatchers.IO) { + isLoading = true + val result = listUmountPaths() + val entries = parseUmountPaths(result) + withContext(Dispatchers.Main) { + pathList = entries + isLoading = false + } + } + } + + LaunchedEffect(Unit) { + loadPaths() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.umount_path_manager)) }, + navigationIcon = { + IconButton(onClick = { navigator.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { loadPaths() }) { + Icon(Icons.Filled.Refresh, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy( + alpha = CardConfig.cardAlpha + ) + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showAddDialog = true } + ) { + Icon(Icons.Filled.Add, contentDescription = null) + } + }, + snackbarHost = { SnackbarHost(snackBarHost) } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + colors = getCardColors(MaterialTheme.colorScheme.primaryContainer), + elevation = getCardElevation() + ) { + Column( + modifier = Modifier.padding(SPACING_LARGE) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + Text( + text = stringResource(R.string.umount_path_restart_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM) + ) { + items(pathList, key = { it.path }) { entry -> + UmountPathCard( + entry = entry, + onDelete = { + scope.launch(Dispatchers.IO) { + val success = removeUmountPath(entry.path) + withContext(Dispatchers.Main) { + if (success) { + snackBarHost.showSnackbar( + context.getString(R.string.umount_path_removed) + ) + loadPaths() + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + } + ) + } + + item { + Spacer(modifier = Modifier.height(SPACING_LARGE)) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE), + horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM) + ) { + Button( + onClick = { + scope.launch { + if (confirmDialog.awaitConfirm( + title = context.getString(R.string.confirm_action), + content = context.getString(R.string.confirm_clear_custom_paths) + ) == ConfirmResult.Confirmed) { + withContext(Dispatchers.IO) { + val success = clearCustomUmountPaths() + withContext(Dispatchers.Main) { + if (success) { + snackBarHost.showSnackbar( + context.getString(R.string.custom_paths_cleared) + ) + loadPaths() + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + } + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.DeleteForever, contentDescription = null) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(stringResource(R.string.clear_custom_paths)) + } + + Button( + onClick = { + scope.launch(Dispatchers.IO) { + val success = applyUmountConfigToKernel() + withContext(Dispatchers.Main) { + if (success) { + snackBarHost.showSnackbar( + context.getString(R.string.config_applied) + ) + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Check, contentDescription = null) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(stringResource(R.string.apply_config)) + } + } + } + } + } + } + + if (showAddDialog) { + AddUmountPathDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { path, flags -> + showAddDialog = false + + scope.launch(Dispatchers.IO) { + val success = addUmountPath(path, flags) + withContext(Dispatchers.Main) { + if (success) { + saveUmountConfig() + snackBarHost.showSnackbar( + context.getString(R.string.umount_path_added) + ) + loadPaths() + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + } + ) + } + } +} + +@Composable +fun UmountPathCard( + entry: UmountPathEntry, + onDelete: () -> Unit +) { + val confirmDialog = rememberConfirmDialog() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow), + elevation = getCardElevation() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(SPACING_LARGE)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.path, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = buildString { + append(context.getString(R.string.flags)) + append(": ") + append(entry.flags.toUmountFlagName(context)) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = { + scope.launch { + if (confirmDialog.awaitConfirm( + title = context.getString(R.string.confirm_delete), + content = context.getString(R.string.confirm_delete_umount_path, entry.path) + ) == ConfirmResult.Confirmed) { + onDelete() + } + } + } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Composable +fun AddUmountPathDialog( + onDismiss: () -> Unit, + onConfirm: (String, Int) -> Unit +) { + var path by rememberSaveable { mutableStateOf("") } + var flags by rememberSaveable { mutableStateOf("-1") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_umount_path)) }, + text = { + Column { + OutlinedTextField( + value = path, + onValueChange = { path = it }, + label = { Text(stringResource(R.string.mount_path)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + + OutlinedTextField( + value = flags, + onValueChange = { flags = it }, + label = { Text(stringResource(R.string.umount_flags)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { Text(stringResource(R.string.umount_flags_hint)) } + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val flagsInt = flags.toIntOrNull() ?: -1 + onConfirm(path, flagsInt) + }, + enabled = path.isNotBlank() + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +private fun parseUmountPaths(output: String): List { + val lines = output.lines().filter { it.isNotBlank() } + if (lines.size < 2) return emptyList() + + return lines.drop(2).mapNotNull { line -> + val parts = line.trim().split(Regex("\\s+")) + if (parts.size >= 2) { + UmountPathEntry( + path = parts[0], + flags = parts[1].toIntOrNull() ?: -1 + ) + } else null + } +} + +private fun Int.toUmountFlagName(context: Context): String { + return when (this) { + -1 -> context.getString(R.string.mnt_detach) + else -> this.toString() + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/SuSFSConfig.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/SuSFSConfig.kt new file mode 100644 index 0000000..00353b2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/SuSFSConfig.kt @@ -0,0 +1,2211 @@ +package com.sukisu.ultra.ui.susfs + +import android.annotation.SuppressLint +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.susfs.component.* +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.susfs.util.SuSFSManager +import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion158 +import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion159 +import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion1512 +import com.sukisu.ultra.ui.util.getSuSFSVersion +import com.sukisu.ultra.ui.util.isAbDevice +import kotlinx.coroutines.launch +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * 标签页枚举类 + */ +enum class SuSFSTab(val displayNameRes: Int) { + BASIC_SETTINGS(R.string.susfs_tab_basic_settings), + SUS_PATHS(R.string.susfs_tab_sus_paths), + SUS_LOOP_PATHS(R.string.susfs_tab_sus_loop_paths), + SUS_MAPS(R.string.susfs_tab_sus_maps), + SUS_MOUNTS(R.string.susfs_tab_sus_mounts), + TRY_UMOUNT(R.string.susfs_tab_try_umount), + KSTAT_CONFIG(R.string.susfs_tab_kstat_config), + PATH_SETTINGS(R.string.susfs_tab_path_settings), + ENABLED_FEATURES(R.string.susfs_tab_enabled_features); + + companion object { + fun getAllTabs(isSusVersion158: Boolean, isSusVersion159: Boolean, isSusVersion1512: Boolean): List { + return when { + isSusVersion1512 -> entries.toList() + isSusVersion159 -> entries.filter { it != SUS_MAPS} + isSusVersion158 -> entries.filter { it != SUS_LOOP_PATHS && it != SUS_MAPS } + else -> entries.filter { it != PATH_SETTINGS && it != SUS_LOOP_PATHS && it != SUS_MAPS } + } + } + } +} + +/** + * SuSFS配置界面 + */ +@SuppressLint("SdCardPath", "AutoboxingStateCreation") +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun SuSFSConfigScreen( + navigator: DestinationsNavigator +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + var selectedTab by remember { mutableStateOf(SuSFSTab.BASIC_SETTINGS) } + var unameValue by remember { mutableStateOf("") } + var buildTimeValue by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var showConfirmReset by remember { mutableStateOf(false) } + var autoStartEnabled by remember { mutableStateOf(false) } + var executeInPostFsData by remember { mutableStateOf(false) } + var enableHideBl by remember { mutableStateOf(true) } + var enableCleanupResidue by remember { mutableStateOf(false) } + var enableAvcLogSpoofing by remember { mutableStateOf(false) } + + // 槽位信息相关状态 + var slotInfoList by remember { mutableStateOf(emptyList()) } + var currentActiveSlot by remember { mutableStateOf("") } + var isLoadingSlotInfo by remember { mutableStateOf(false) } + var showSlotInfoDialog by remember { mutableStateOf(false) } + + // 路径管理相关状态 + var susPaths by remember { mutableStateOf(emptySet()) } + var susLoopPaths by remember { mutableStateOf(emptySet()) } + var susMaps by remember { mutableStateOf(emptySet()) } + var susMounts by remember { mutableStateOf(emptySet()) } + var tryUmounts by remember { mutableStateOf(emptySet()) } + var androidDataPath by remember { mutableStateOf("") } + var sdcardPath by remember { mutableStateOf("") } + + // SUS挂载隐藏控制状态 + var hideSusMountsForAllProcs by remember { mutableStateOf(true) } + + var umountForZygoteIsoService by remember { mutableStateOf(false) } + + // Kstat配置相关状态 + var kstatConfigs by remember { mutableStateOf(emptySet()) } + var addKstatPaths by remember { mutableStateOf(emptySet()) } + + // 启用功能状态相关 + var enabledFeatures by remember { mutableStateOf(emptyList()) } + var isLoadingFeatures by remember { mutableStateOf(false) } + + // 应用列表相关状态 + var installedApps by remember { mutableStateOf(emptyList()) } + + // 对话框状态 + var showAddPathDialog by remember { mutableStateOf(false) } + var showAddLoopPathDialog by remember { mutableStateOf(false) } + var showAddSusMapDialog by remember { mutableStateOf(false) } + var showAddAppPathDialog by remember { mutableStateOf(false) } + var showAddMountDialog by remember { mutableStateOf(false) } + var showAddUmountDialog by remember { mutableStateOf(false) } + var showAddKstatStaticallyDialog by remember { mutableStateOf(false) } + var showAddKstatDialog by remember { mutableStateOf(false) } + + // 编辑状态 + var editingPath by remember { mutableStateOf(null) } + var editingLoopPath by remember { mutableStateOf(null) } + var editingSusMap by remember { mutableStateOf(null) } + var editingMount by remember { mutableStateOf(null) } + var editingUmount by remember { mutableStateOf(null) } + var editingKstatConfig by remember { mutableStateOf(null) } + var editingKstatPath by remember { mutableStateOf(null) } + + // 重置确认对话框状态 + var showResetPathsDialog by remember { mutableStateOf(false) } + var showResetLoopPathsDialog by remember { mutableStateOf(false) } + var showResetSusMapsDialog by remember { mutableStateOf(false) } + var showResetMountsDialog by remember { mutableStateOf(false) } + var showResetUmountsDialog by remember { mutableStateOf(false) } + var showResetKstatDialog by remember { mutableStateOf(false) } + + // 备份还原相关状态 + var showBackupDialog by remember { mutableStateOf(false) } + var showRestoreDialog by remember { mutableStateOf(false) } + var showRestoreConfirmDialog by remember { mutableStateOf(false) } + var selectedBackupFile by remember { mutableStateOf(null) } + var backupInfo by remember { mutableStateOf(null) } + + var isNavigating by remember { mutableStateOf(false) } + + val allTabs = SuSFSTab.getAllTabs(isSusVersion158(), isSusVersion159(), isSusVersion1512()) + + // 实时判断是否可以启用开机自启动 + val canEnableAutoStart by remember { + derivedStateOf { + SuSFSManager.hasConfigurationForAutoStart(context) + } + } + + var showVersionMismatchDialog by remember { mutableStateOf(false) } + + if (showVersionMismatchDialog) { + AlertDialog( + onDismissRequest = { showVersionMismatchDialog = false }, + title = { + Text( + text = stringResource(R.string.warning), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + stringResource( + R.string.susfs_version_mismatch, + try { getSuSFSVersion() } catch (_: Exception) { "unknown" }, + SuSFSManager.MAX_SUSFS_VERSION + ) + ) + }, + confirmButton = { + TextButton( + onClick = { showVersionMismatchDialog = false }, + modifier = Modifier.padding(8.dp) + ) { + Text(stringResource(R.string.confirm)) + } + } + ) + } + + // 文件选择器 + val backupFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + uri?.let { fileUri -> + val fileName = SuSFSManager.getDefaultBackupFileName() + val tempFile = File(context.cacheDir, fileName) + coroutineScope.launch { + isLoading = true + val success = SuSFSManager.createBackup(context, tempFile.absolutePath) + if (success) { + try { + context.contentResolver.openOutputStream(fileUri)?.use { outputStream -> + tempFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + tempFile.delete() + } + isLoading = false + showBackupDialog = false + } + } + } + + val restoreFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { fileUri -> + coroutineScope.launch { + try { + val tempFile = File(context.cacheDir, "temp_restore.susfs_backup") + context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + tempFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + // 验证备份文件 + val backup = SuSFSManager.validateBackupFile(tempFile.absolutePath) + if (backup != null) { + selectedBackupFile = tempFile.absolutePath + backupInfo = backup + showRestoreConfirmDialog = true + } + tempFile.deleteOnExit() + } catch (e: Exception) { + e.printStackTrace() + } + showRestoreDialog = false + } + } + } + + // 加载启用功能状态 + fun loadEnabledFeatures() { + coroutineScope.launch { + isLoadingFeatures = true + enabledFeatures = SuSFSManager.getEnabledFeatures(context) + isLoadingFeatures = false + } + } + + // 加载应用列表 + fun loadInstalledApps() { + coroutineScope.launch { + installedApps = SuSFSManager.getInstalledApps() + } + } + + // 加载槽位信息 + fun loadSlotInfo() { + coroutineScope.launch { + isLoadingSlotInfo = true + slotInfoList = SuSFSManager.getCurrentSlotInfo() + currentActiveSlot = SuSFSManager.getCurrentActiveSlot() + isLoadingSlotInfo = false + } + } + + // 加载当前配置 + LaunchedEffect(Unit) { + coroutineScope.launch { + try { + val version = getSuSFSVersion() + val binaryName = "ksu_susfs_${version.removePrefix("v")}" + + val isBinaryAvailable = try { + context.assets.open(binaryName).use { true } + } catch (_: Exception) { false } + + if (!isBinaryAvailable) { + showVersionMismatchDialog = true + } + } catch (_: Exception) { + } + + unameValue = SuSFSManager.getUnameValue(context) + buildTimeValue = SuSFSManager.getBuildTimeValue(context) + autoStartEnabled = SuSFSManager.isAutoStartEnabled(context) + executeInPostFsData = SuSFSManager.getExecuteInPostFsData(context) + susPaths = SuSFSManager.getSusPaths(context) + susLoopPaths = SuSFSManager.getSusLoopPaths(context) + susMaps = SuSFSManager.getSusMaps(context) + susMounts = SuSFSManager.getSusMounts(context) + tryUmounts = SuSFSManager.getTryUmounts(context) + androidDataPath = SuSFSManager.getAndroidDataPath(context) + sdcardPath = SuSFSManager.getSdcardPath(context) + kstatConfigs = SuSFSManager.getKstatConfigs(context) + addKstatPaths = SuSFSManager.getAddKstatPaths(context) + hideSusMountsForAllProcs = SuSFSManager.getHideSusMountsForAllProcs(context) + enableHideBl = SuSFSManager.getEnableHideBl(context) + enableCleanupResidue = SuSFSManager.getEnableCleanupResidue(context) + umountForZygoteIsoService = SuSFSManager.getUmountForZygoteIsoService(context) + enableAvcLogSpoofing = SuSFSManager.getEnableAvcLogSpoofing(context) + + loadSlotInfo() + } + } + + // 当切换到启用功能状态标签页时加载数据 + LaunchedEffect(selectedTab) { + if (selectedTab == SuSFSTab.ENABLED_FEATURES) { + loadEnabledFeatures() + } + } + + // 当配置变化时,自动调整开机自启动状态 + LaunchedEffect(canEnableAutoStart) { + if (!canEnableAutoStart && autoStartEnabled) { + autoStartEnabled = false + SuSFSManager.configureAutoStart(context, false) + } + } + + // 备份对话框 + if (showBackupDialog) { + AlertDialog( + onDismissRequest = { showBackupDialog = false }, + title = { + Text( + text = stringResource(R.string.susfs_backup_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Text(stringResource(R.string.susfs_backup_description)) + }, + confirmButton = { + Button( + onClick = { + val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) + val timestamp = dateFormat.format(Date()) + backupFileLauncher.launch("SuSFS_Config_$timestamp.susfs_backup") + }, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.susfs_backup_create)) + } + }, + dismissButton = { + TextButton( + onClick = { showBackupDialog = false }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } + + // 还原对话框 + if (showRestoreDialog) { + AlertDialog( + onDismissRequest = { showRestoreDialog = false }, + title = { + Text( + text = stringResource(R.string.susfs_restore_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Text(stringResource(R.string.susfs_restore_description)) + }, + confirmButton = { + Button( + onClick = { + restoreFileLauncher.launch(arrayOf("application/json", "*/*")) + }, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.susfs_restore_select_file)) + } + }, + dismissButton = { + TextButton( + onClick = { showRestoreDialog = false }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } + + // 还原确认对话框 + if (showRestoreConfirmDialog && backupInfo != null) { + AlertDialog( + onDismissRequest = { + showRestoreConfirmDialog = false + selectedBackupFile = null + backupInfo = null + }, + title = { + Text( + text = stringResource(R.string.susfs_restore_confirm_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(R.string.susfs_restore_confirm_description)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + Text( + text = stringResource(R.string.susfs_backup_info_date, + dateFormat.format(Date(backupInfo!!.timestamp))), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = stringResource(R.string.susfs_backup_info_device, backupInfo!!.deviceInfo), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = stringResource(R.string.susfs_backup_info_version, backupInfo!!.version), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + selectedBackupFile?.let { filePath -> + coroutineScope.launch { + isLoading = true + val success = SuSFSManager.restoreFromBackup(context, filePath) + if (success) { + // 重新加载所有配置 + unameValue = SuSFSManager.getUnameValue(context) + buildTimeValue = SuSFSManager.getBuildTimeValue(context) + autoStartEnabled = SuSFSManager.isAutoStartEnabled(context) + executeInPostFsData = SuSFSManager.getExecuteInPostFsData(context) + susPaths = SuSFSManager.getSusPaths(context) + susLoopPaths = SuSFSManager.getSusLoopPaths(context) + susMaps = SuSFSManager.getSusMaps(context) + susMounts = SuSFSManager.getSusMounts(context) + tryUmounts = SuSFSManager.getTryUmounts(context) + androidDataPath = SuSFSManager.getAndroidDataPath(context) + sdcardPath = SuSFSManager.getSdcardPath(context) + kstatConfigs = SuSFSManager.getKstatConfigs(context) + addKstatPaths = SuSFSManager.getAddKstatPaths(context) + hideSusMountsForAllProcs = SuSFSManager.getHideSusMountsForAllProcs(context) + enableHideBl = SuSFSManager.getEnableHideBl(context) + enableCleanupResidue = SuSFSManager.getEnableCleanupResidue(context) + umountForZygoteIsoService = SuSFSManager.getUmountForZygoteIsoService(context) + enableAvcLogSpoofing = SuSFSManager.getEnableAvcLogSpoofing(context) + } + isLoading = false + showRestoreConfirmDialog = false + selectedBackupFile = null + backupInfo = null + } + } + }, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.susfs_restore_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { + showRestoreConfirmDialog = false + selectedBackupFile = null + backupInfo = null + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } + + // 槽位信息对话框 + SlotInfoDialog( + showDialog = showSlotInfoDialog, + onDismiss = { showSlotInfoDialog = false }, + slotInfoList = slotInfoList, + currentActiveSlot = currentActiveSlot, + isLoadingSlotInfo = isLoadingSlotInfo, + onRefresh = { loadSlotInfo() }, + onUseUname = { uname -> + unameValue = uname + showSlotInfoDialog = false + }, + onUseBuildTime = { buildTime -> + buildTimeValue = buildTime + showSlotInfoDialog = false + } + ) + + // 各种对话框 + AddPathDialog( + showDialog = showAddPathDialog, + onDismiss = { + showAddPathDialog = false + editingPath = null + }, + onConfirm = { path -> + coroutineScope.launch { + isLoading = true + val success = if (editingPath != null) { + SuSFSManager.editSusPath(context, editingPath!!, path) + } else { + SuSFSManager.addSusPath(context, path) + } + if (success) { + susPaths = SuSFSManager.getSusPaths(context) + } + isLoading = false + showAddPathDialog = false + editingPath = null + } + }, + isLoading = isLoading, + titleRes = if (editingPath != null) R.string.susfs_edit_sus_path else R.string.susfs_add_sus_path, + labelRes = R.string.susfs_path_label, + placeholderRes = R.string.susfs_path_placeholder, + initialValue = editingPath ?: "" + ) + + AddPathDialog( + showDialog = showAddLoopPathDialog, + onDismiss = { + showAddLoopPathDialog = false + editingLoopPath = null + }, + onConfirm = { path -> + coroutineScope.launch { + isLoading = true + val success = if (editingLoopPath != null) { + SuSFSManager.editSusLoopPath(context, editingLoopPath!!, path) + } else { + SuSFSManager.addSusLoopPath(context, path) + } + if (success) { + susLoopPaths = SuSFSManager.getSusLoopPaths(context) + } + isLoading = false + showAddLoopPathDialog = false + editingLoopPath = null + } + }, + isLoading = isLoading, + titleRes = if (editingLoopPath != null) R.string.susfs_edit_sus_loop_path else R.string.susfs_add_sus_loop_path, + labelRes = R.string.susfs_loop_path_label, + placeholderRes = R.string.susfs_loop_path_placeholder, + initialValue = editingLoopPath ?: "" + ) + + AddPathDialog( + showDialog = showAddSusMapDialog, + onDismiss = { + showAddSusMapDialog = false + editingSusMap = null + }, + onConfirm = { path -> + coroutineScope.launch { + isLoading = true + val success = if (editingSusMap != null) { + SuSFSManager.editSusMap(context, editingSusMap!!, path) + } else { + SuSFSManager.addSusMap(context, path) + } + if (success) { + susMaps = SuSFSManager.getSusMaps(context) + } + isLoading = false + showAddSusMapDialog = false + editingSusMap = null + } + }, + isLoading = isLoading, + titleRes = if (editingSusMap != null) R.string.susfs_edit_sus_map else R.string.susfs_add_sus_map, + labelRes = R.string.susfs_sus_map_label, + placeholderRes = R.string.susfs_sus_map_placeholder, + initialValue = editingSusMap ?: "" + ) + + AddAppPathDialog( + showDialog = showAddAppPathDialog, + onDismiss = { showAddAppPathDialog = false }, + onConfirm = { packageNames -> + coroutineScope.launch { + isLoading = true + var successCount = 0 + packageNames.forEach { packageName -> + if (SuSFSManager.addAppPaths(context, packageName)) { + successCount++ + } + } + if (successCount > 0) { + susPaths = SuSFSManager.getSusPaths(context) + } + isLoading = false + showAddAppPathDialog = false + } + }, + isLoading = isLoading, + apps = installedApps, + onLoadApps = { loadInstalledApps() }, + existingSusPaths = susPaths + ) + + AddPathDialog( + showDialog = showAddMountDialog, + onDismiss = { + showAddMountDialog = false + editingMount = null + }, + onConfirm = { mount -> + coroutineScope.launch { + isLoading = true + val success = if (editingMount != null) { + SuSFSManager.editSusMount(context, editingMount!!, mount) + } else { + SuSFSManager.addSusMount(context, mount) + } + if (success) { + susMounts = SuSFSManager.getSusMounts(context) + } + isLoading = false + showAddMountDialog = false + editingMount = null + } + }, + isLoading = isLoading, + titleRes = if (editingMount != null) R.string.susfs_edit_sus_mount else R.string.susfs_add_sus_mount, + labelRes = R.string.susfs_mount_path_label, + placeholderRes = R.string.susfs_path_placeholder, + initialValue = editingMount ?: "" + ) + + AddTryUmountDialog( + showDialog = showAddUmountDialog, + onDismiss = { + showAddUmountDialog = false + editingUmount = null + }, + onConfirm = { path, mode -> + coroutineScope.launch { + isLoading = true + val success = if (editingUmount != null) { + SuSFSManager.editTryUmount(context, editingUmount!!, path, mode) + } else { + SuSFSManager.addTryUmount(context, path, mode) + } + if (success) { + tryUmounts = SuSFSManager.getTryUmounts(context) + } + isLoading = false + showAddUmountDialog = false + editingUmount = null + } + }, + isLoading = isLoading, + initialPath = editingUmount?.split("|")?.get(0) ?: "", + initialMode = editingUmount?.split("|")?.get(1)?.toIntOrNull() ?: 0 + ) + + AddKstatStaticallyDialog( + showDialog = showAddKstatStaticallyDialog, + onDismiss = { + showAddKstatStaticallyDialog = false + editingKstatConfig = null + }, + onConfirm = { path, ino, dev, nlink, size, atime, atimeNsec, mtime, mtimeNsec, ctime, ctimeNsec, blocks, blksize -> + coroutineScope.launch { + isLoading = true + val success = if (editingKstatConfig != null) { + SuSFSManager.editKstatConfig( + context, + editingKstatConfig!!, + path, + ino, + dev, + nlink, + size, + atime, + atimeNsec, + mtime, + mtimeNsec, + ctime, + ctimeNsec, + blocks, + blksize + ) + } else { + SuSFSManager.addKstatStatically( + context, path, ino, dev, nlink, size, atime, atimeNsec, + mtime, mtimeNsec, ctime, ctimeNsec, blocks, blksize + ) + } + if (success) { + kstatConfigs = SuSFSManager.getKstatConfigs(context) + } + isLoading = false + showAddKstatStaticallyDialog = false + editingKstatConfig = null + } + }, + isLoading = isLoading, + initialConfig = editingKstatConfig ?: "" + ) + + AddPathDialog( + showDialog = showAddKstatDialog, + onDismiss = { + showAddKstatDialog = false + editingKstatPath = null + }, + onConfirm = { path -> + coroutineScope.launch { + isLoading = true + val success = if (editingKstatPath != null) { + SuSFSManager.editAddKstat(context, editingKstatPath!!, path) + } else { + SuSFSManager.addKstat(context, path) + } + if (success) { + addKstatPaths = SuSFSManager.getAddKstatPaths(context) + } + isLoading = false + showAddKstatDialog = false + editingKstatPath = null + } + }, + isLoading = isLoading, + titleRes = if (editingKstatPath != null) R.string.edit_kstat_path_title else R.string.add_kstat_path_title, + labelRes = R.string.file_or_directory_path_label, + placeholderRes = R.string.susfs_path_placeholder, + initialValue = editingKstatPath ?: "" + ) + + // 确认对话框 + ConfirmDialog( + showDialog = showConfirmReset, + onDismiss = { showConfirmReset = false }, + onConfirm = { + showConfirmReset = false + coroutineScope.launch { + isLoading = true + if (SuSFSManager.resetToDefault(context)) { + unameValue = "default" + buildTimeValue = "default" + autoStartEnabled = false + } + isLoading = false + } + }, + titleRes = R.string.susfs_reset_confirm_title, + messageRes = R.string.susfs_reset_confirm_title, + isLoading = isLoading, + isDestructive = true + ) + + // 重置对话框 + ConfirmDialog( + showDialog = showResetPathsDialog, + onDismiss = { showResetPathsDialog = false }, + onConfirm = { + coroutineScope.launch { + isLoading = true + SuSFSManager.saveSusPaths(context, emptySet()) + susPaths = emptySet() + if (SuSFSManager.isAutoStartEnabled(context)) { + SuSFSManager.configureAutoStart(context, true) + } + isLoading = false + showResetPathsDialog = false + } + }, + titleRes = R.string.susfs_reset_paths_title, + messageRes = R.string.susfs_reset_paths_message, + isLoading = isLoading, + isDestructive = true + ) + + ConfirmDialog( + showDialog = showResetLoopPathsDialog, + onDismiss = { showResetLoopPathsDialog = false }, + onConfirm = { + coroutineScope.launch { + isLoading = true + SuSFSManager.saveSusLoopPaths(context, emptySet()) + susLoopPaths = emptySet() + if (SuSFSManager.isAutoStartEnabled(context)) { + SuSFSManager.configureAutoStart(context, true) + } + isLoading = false + showResetLoopPathsDialog = false + } + }, + titleRes = R.string.susfs_reset_loop_paths_title, + messageRes = R.string.susfs_reset_loop_paths_message, + isLoading = isLoading, + isDestructive = true + ) + + ConfirmDialog( + showDialog = showResetSusMapsDialog, + onDismiss = { showResetSusMapsDialog = false }, + onConfirm = { + coroutineScope.launch { + isLoading = true + SuSFSManager.saveSusMaps(context, emptySet()) + susMaps = emptySet() + if (SuSFSManager.isAutoStartEnabled(context)) { + SuSFSManager.configureAutoStart(context, true) + } + isLoading = false + showResetSusMapsDialog = false + } + }, + titleRes = R.string.susfs_reset_sus_maps_title, + messageRes = R.string.susfs_reset_sus_maps_message, + isLoading = isLoading, + isDestructive = true + ) + + ConfirmDialog( + showDialog = showResetMountsDialog, + onDismiss = { showResetMountsDialog = false }, + onConfirm = { + coroutineScope.launch { + isLoading = true + SuSFSManager.saveSusMounts(context, emptySet()) + susMounts = emptySet() + if (SuSFSManager.isAutoStartEnabled(context)) { + SuSFSManager.configureAutoStart(context, true) + } + isLoading = false + showResetMountsDialog = false + } + }, + titleRes = R.string.susfs_reset_mounts_title, + messageRes = R.string.susfs_reset_mounts_message, + isLoading = isLoading, + isDestructive = true + ) + + ConfirmDialog( + showDialog = showResetUmountsDialog, + onDismiss = { showResetUmountsDialog = false }, + onConfirm = { + coroutineScope.launch { + isLoading = true + SuSFSManager.saveTryUmounts(context, emptySet()) + tryUmounts = emptySet() + if (SuSFSManager.isAutoStartEnabled(context)) { + SuSFSManager.configureAutoStart(context, true) + } + isLoading = false + showResetUmountsDialog = false + } + }, + titleRes = R.string.susfs_reset_umounts_title, + messageRes = R.string.susfs_reset_umounts_message, + isLoading = isLoading, + isDestructive = true + ) + + ConfirmDialog( + showDialog = showResetKstatDialog, + onDismiss = { showResetKstatDialog = false }, + onConfirm = { + coroutineScope.launch { + isLoading = true + SuSFSManager.saveKstatConfigs(context, emptySet()) + SuSFSManager.saveAddKstatPaths(context, emptySet()) + kstatConfigs = emptySet() + addKstatPaths = emptySet() + if (SuSFSManager.isAutoStartEnabled(context)) { + SuSFSManager.configureAutoStart(context, true) + } + isLoading = false + showResetKstatDialog = false + } + }, + titleRes = R.string.reset_kstat_config_title, + messageRes = R.string.reset_kstat_config_message, + isLoading = isLoading, + isDestructive = true + ) + + // 主界面布局 + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.susfs_config_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + }, + navigationIcon = { + IconButton(onClick = { + if (!isNavigating) { + isNavigating = true + navigator.popBackStack() + } + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha), + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha) + ), + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) + }, + bottomBar = { + // 统一的底部按钮栏 + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.Transparent, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + when (selectedTab) { + SuSFSTab.BASIC_SETTINGS -> { + // 应用按钮 + Button( + onClick = { + if (unameValue.isNotBlank() || buildTimeValue.isNotBlank()) { + coroutineScope.launch { + isLoading = true + val finalUnameValue = unameValue.trim().ifBlank { "default" } + val finalBuildTimeValue = buildTimeValue.trim().ifBlank { "default" } + val success = SuSFSManager.setUname(context, finalUnameValue, finalBuildTimeValue) + if (success) { + SuSFSManager.saveExecuteInPostFsData(context, executeInPostFsData) + SuSFSManager.saveEnableHideBl(context, enableHideBl) + SuSFSManager.saveEnableCleanupResidue(context, enableCleanupResidue) + SuSFSManager.saveEnableAvcLogSpoofing(context, enableAvcLogSpoofing) + } + isLoading = false + } + } + }, + enabled = !isLoading && (unameValue.isNotBlank() || buildTimeValue.isNotBlank()), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .weight(1f) + .height(40.dp) + ) { + Text( + stringResource(R.string.susfs_apply), + fontWeight = FontWeight.Medium + ) + } + + // 重置按钮 + OutlinedButton( + onClick = { showConfirmReset = true }, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .weight(1f) + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_to_default), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.SUS_PATHS -> { + OutlinedButton( + onClick = { showResetPathsDialog = true }, + enabled = !isLoading && susPaths.isNotEmpty(), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_paths_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.SUS_LOOP_PATHS -> { + OutlinedButton( + onClick = { showResetLoopPathsDialog = true }, + enabled = !isLoading && susLoopPaths.isNotEmpty(), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_loop_paths_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.SUS_MAPS -> { + OutlinedButton( + onClick = { showResetSusMapsDialog = true }, + enabled = !isLoading && susMaps.isNotEmpty(), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_sus_maps_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.SUS_MOUNTS -> { + OutlinedButton( + onClick = { showResetMountsDialog = true }, + enabled = !isLoading && susMounts.isNotEmpty(), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_mounts_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.TRY_UMOUNT -> { + OutlinedButton( + onClick = { showResetUmountsDialog = true }, + enabled = !isLoading && tryUmounts.isNotEmpty(), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_umounts_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.KSTAT_CONFIG -> { + OutlinedButton( + onClick = { showResetKstatDialog = true }, + enabled = !isLoading && (kstatConfigs.isNotEmpty() || addKstatPaths.isNotEmpty()), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.reset_kstat_config_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.PATH_SETTINGS -> { + OutlinedButton( + onClick = { + androidDataPath = "/sdcard/Android/data" + sdcardPath = "/sdcard" + coroutineScope.launch { + isLoading = true + SuSFSManager.setAndroidDataPath(context, androidDataPath) + SuSFSManager.setSdcardPath(context, sdcardPath) + isLoading = false + } + }, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.RestoreFromTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_reset_path_title), + fontWeight = FontWeight.Medium + ) + } + } + + SuSFSTab.ENABLED_FEATURES -> { + Button( + onClick = { loadEnabledFeatures() }, + enabled = !isLoadingFeatures, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.refresh), + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 12.dp) + ) { + // 标签页 + PrimaryScrollableTabRow( + selectedTabIndex = allTabs.indexOf(selectedTab), + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + edgePadding = 0.dp + ) { + allTabs.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { + Text( + text = stringResource(tab.displayNameRes), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp, + fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal + ) + }, + modifier = Modifier.padding(horizontal = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 标签页内容 + Box( + modifier = Modifier.fillMaxSize() + ) { + when (selectedTab) { + SuSFSTab.BASIC_SETTINGS -> { + BasicSettingsContent( + unameValue = unameValue, + onUnameValueChange = { unameValue = it }, + buildTimeValue = buildTimeValue, + onBuildTimeValueChange = { buildTimeValue = it }, + executeInPostFsData = executeInPostFsData, + onExecuteInPostFsDataChange = { executeInPostFsData = it }, + autoStartEnabled = autoStartEnabled, + canEnableAutoStart = canEnableAutoStart, + isLoading = isLoading, + onAutoStartToggle = { enabled -> + if (canEnableAutoStart) { + coroutineScope.launch { + isLoading = true + if (SuSFSManager.configureAutoStart(context, enabled)) { + autoStartEnabled = enabled + } + isLoading = false + } + } + }, + onShowSlotInfo = { showSlotInfoDialog = true }, + context = context, + onShowBackupDialog = { showBackupDialog = true }, + onShowRestoreDialog = { showRestoreDialog = true }, + enableHideBl = enableHideBl, + onEnableHideBlChange = { enabled -> + enableHideBl = enabled + SuSFSManager.saveEnableHideBl(context, enabled) + if (SuSFSManager.isAutoStartEnabled(context)) { + coroutineScope.launch { + SuSFSManager.configureAutoStart(context, true) + } + } + }, + enableCleanupResidue = enableCleanupResidue, + onEnableCleanupResidueChange = { enabled -> + enableCleanupResidue = enabled + SuSFSManager.saveEnableCleanupResidue(context, enabled) + if (SuSFSManager.isAutoStartEnabled(context)) { + coroutineScope.launch { + SuSFSManager.configureAutoStart(context, true) + } + } + }, + enableAvcLogSpoofing = enableAvcLogSpoofing, + onEnableAvcLogSpoofingChange = { enabled -> + coroutineScope.launch { + isLoading = true + val success = SuSFSManager.setEnableAvcLogSpoofing(context, enabled) + if (success) { + enableAvcLogSpoofing = enabled + } + isLoading = false + } + } + ) + } + SuSFSTab.SUS_PATHS -> { + SusPathsContent( + susPaths = susPaths, + isLoading = isLoading, + onAddPath = { showAddPathDialog = true }, + onAddAppPath = { showAddAppPathDialog = true }, + onRemovePath = { path -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeSusPath(context, path)) { + susPaths = SuSFSManager.getSusPaths(context) + } + isLoading = false + } + }, + onEditPath = { path -> + editingPath = path + showAddPathDialog = true + }, + forceRefreshApps = selectedTab == SuSFSTab.SUS_PATHS + ) + } + SuSFSTab.SUS_LOOP_PATHS -> { + SusLoopPathsContent( + susLoopPaths = susLoopPaths, + isLoading = isLoading, + onAddLoopPath = { showAddLoopPathDialog = true }, + onRemoveLoopPath = { path -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeSusLoopPath(context, path)) { + susLoopPaths = SuSFSManager.getSusLoopPaths(context) + } + isLoading = false + } + }, + onEditLoopPath = { path -> + editingLoopPath = path + showAddLoopPathDialog = true + } + ) + } + SuSFSTab.SUS_MAPS -> { + SusMapsContent( + susMaps = susMaps, + isLoading = isLoading, + onAddSusMap = { showAddSusMapDialog = true }, + onRemoveSusMap = { map -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeSusMap(context, map)) { + susMaps = SuSFSManager.getSusMaps(context) + } + isLoading = false + } + }, + onEditSusMap = { map -> + editingSusMap = map + showAddSusMapDialog = true + } + ) + } + SuSFSTab.SUS_MOUNTS -> { + val isSusVersion158 = remember { isSusVersion158() } + + SusMountsContent( + susMounts = susMounts, + hideSusMountsForAllProcs = hideSusMountsForAllProcs, + isSusVersion158 = isSusVersion158, + isLoading = isLoading, + onAddMount = { showAddMountDialog = true }, + onRemoveMount = { mount -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeSusMount(context, mount)) { + susMounts = SuSFSManager.getSusMounts(context) + } + isLoading = false + } + }, + onEditMount = { mount -> + editingMount = mount + showAddMountDialog = true + }, + onToggleHideSusMountsForAllProcs = { hideForAll -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.setHideSusMountsForAllProcs( + context, + hideForAll + ) + ) { + hideSusMountsForAllProcs = hideForAll + } + isLoading = false + } + } + ) + } + + SuSFSTab.TRY_UMOUNT -> { + TryUmountContent( + tryUmounts = tryUmounts, + umountForZygoteIsoService = umountForZygoteIsoService, + isLoading = isLoading, + onAddUmount = { showAddUmountDialog = true }, + onRemoveUmount = { umountEntry -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeTryUmount(context, umountEntry)) { + tryUmounts = SuSFSManager.getTryUmounts(context) + } + isLoading = false + } + }, + onEditUmount = { umountEntry -> + editingUmount = umountEntry + showAddUmountDialog = true + }, + onToggleUmountForZygoteIsoService = { enabled -> + coroutineScope.launch { + isLoading = true + val success = + SuSFSManager.setUmountForZygoteIsoService(context, enabled) + if (success) { + umountForZygoteIsoService = enabled + } + isLoading = false + } + } + ) + } + + SuSFSTab.KSTAT_CONFIG -> { + KstatConfigContent( + kstatConfigs = kstatConfigs, + addKstatPaths = addKstatPaths, + isLoading = isLoading, + onAddKstatStatically = { showAddKstatStaticallyDialog = true }, + onAddKstat = { showAddKstatDialog = true }, + onRemoveKstatConfig = { config -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeKstatConfig(context, config)) { + kstatConfigs = SuSFSManager.getKstatConfigs(context) + } + isLoading = false + } + }, + onEditKstatConfig = { config -> + editingKstatConfig = config + showAddKstatStaticallyDialog = true + }, + onRemoveAddKstat = { path -> + coroutineScope.launch { + isLoading = true + if (SuSFSManager.removeAddKstat(context, path)) { + addKstatPaths = SuSFSManager.getAddKstatPaths(context) + } + isLoading = false + } + }, + onEditAddKstat = { path -> + editingKstatPath = path + showAddKstatDialog = true + }, + onUpdateKstat = { path -> + coroutineScope.launch { + isLoading = true + SuSFSManager.updateKstat(context, path) + isLoading = false + } + }, + onUpdateKstatFullClone = { path -> + coroutineScope.launch { + isLoading = true + SuSFSManager.updateKstatFullClone(context, path) + isLoading = false + } + } + ) + } + SuSFSTab.PATH_SETTINGS -> { + PathSettingsContent( + androidDataPath = androidDataPath, + onAndroidDataPathChange = { androidDataPath = it }, + sdcardPath = sdcardPath, + onSdcardPathChange = { sdcardPath = it }, + isLoading = isLoading, + onSetAndroidDataPath = { + coroutineScope.launch { + isLoading = true + SuSFSManager.setAndroidDataPath(context, androidDataPath.trim()) + isLoading = false + } + }, + onSetSdcardPath = { + coroutineScope.launch { + isLoading = true + SuSFSManager.setSdcardPath(context, sdcardPath.trim()) + isLoading = false + } + } + ) + } + SuSFSTab.ENABLED_FEATURES -> { + EnabledFeaturesContent( + enabledFeatures = enabledFeatures, + onRefresh = { loadEnabledFeatures() } + ) + } + } + } + } + } +} + +/** + * 基本设置内容组件 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BasicSettingsContent( + unameValue: String, + onUnameValueChange: (String) -> Unit, + buildTimeValue: String, + onBuildTimeValueChange: (String) -> Unit, + executeInPostFsData: Boolean, + onExecuteInPostFsDataChange: (Boolean) -> Unit, + autoStartEnabled: Boolean, + canEnableAutoStart: Boolean, + isLoading: Boolean, + onAutoStartToggle: (Boolean) -> Unit, + onShowSlotInfo: () -> Unit, + context: Context, + onShowBackupDialog: () -> Unit, + onShowRestoreDialog: () -> Unit, + enableHideBl: Boolean, + onEnableHideBlChange: (Boolean) -> Unit, + enableCleanupResidue: Boolean, + onEnableCleanupResidueChange: (Boolean) -> Unit, + enableAvcLogSpoofing: Boolean, + onEnableAvcLogSpoofingChange: (Boolean) -> Unit +) { + var scriptLocationExpanded by remember { mutableStateOf(false) } + val isAbDevice = produceState(initialValue = false) { + value = isAbDevice() + }.value + val isSusVersion159 = isSusVersion159() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 说明卡片 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Text( + text = stringResource(R.string.susfs_config_description), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.susfs_config_description_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 16.sp + ) + } + } + + // Uname输入框 + OutlinedTextField( + value = unameValue, + onValueChange = onUnameValueChange, + label = { Text(stringResource(R.string.susfs_uname_label)) }, + placeholder = { Text(stringResource(R.string.susfs_uname_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + // 构建时间伪装输入框 + OutlinedTextField( + value = buildTimeValue, + onValueChange = onBuildTimeValueChange, + label = { Text(stringResource(R.string.susfs_build_time_label)) }, + placeholder = { Text(stringResource(R.string.susfs_build_time_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + // 执行位置选择 + ExposedDropdownMenuBox( + expanded = scriptLocationExpanded, + onExpandedChange = { scriptLocationExpanded = !scriptLocationExpanded } + ) { + OutlinedTextField( + value = if (executeInPostFsData) + stringResource(R.string.susfs_execution_location_post_fs_data) + else + stringResource(R.string.susfs_execution_location_service), + onValueChange = { }, + readOnly = true, + label = { Text(stringResource(R.string.susfs_execution_location_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = scriptLocationExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true), + shape = RoundedCornerShape(8.dp), + enabled = !isLoading + ) + ExposedDropdownMenu( + expanded = scriptLocationExpanded, + onDismissRequest = { scriptLocationExpanded = false } + ) { + DropdownMenuItem( + text = { + Column { + Text(stringResource(R.string.susfs_execution_location_service)) + Text( + stringResource(R.string.susfs_execution_location_service_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onExecuteInPostFsDataChange(false) + scriptLocationExpanded = false + } + ) + DropdownMenuItem( + text = { + Column { + Text(stringResource(R.string.susfs_execution_location_post_fs_data)) + Text( + stringResource(R.string.susfs_execution_location_post_fs_data_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onExecuteInPostFsDataChange(true) + scriptLocationExpanded = false + } + ) + } + } + + // 当前值显示 + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.susfs_current_value, SuSFSManager.getUnameValue(context)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.susfs_current_build_time, SuSFSManager.getBuildTimeValue(context)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.susfs_current_execution_location, if (SuSFSManager.getExecuteInPostFsData(context)) "Post-FS-Data" else "Service"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // 开机自启动开关 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (canEnableAutoStart) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.AutoMode, + contentDescription = null, + tint = if (canEnableAutoStart) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.susfs_autostart_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = if (canEnableAutoStart) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + } + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = if (canEnableAutoStart) { + stringResource(R.string.susfs_autostart_description) + } else { + stringResource(R.string.susfs_autostart_requirement) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (canEnableAutoStart) 1f else 0.5f + ), + lineHeight = 14.sp + ) + } + Switch( + checked = autoStartEnabled, + onCheckedChange = onAutoStartToggle, + enabled = !isLoading && canEnableAutoStart + ) + } + } + + // 隐藏BL脚本开关 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.hide_bl_script), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.hide_bl_script_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp + ) + } + Switch( + checked = enableHideBl, + onCheckedChange = onEnableHideBlChange, + enabled = !isLoading + ) + } + } + + // 清理残留脚本开关 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CleaningServices, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.cleanup_residue), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.cleanup_residue_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp + ) + } + Switch( + checked = enableCleanupResidue, + onCheckedChange = onEnableCleanupResidueChange, + enabled = !isLoading + ) + } + } + + // AVC日志欺骗开关(仅在1.5.9+版本显示) + if (isSusVersion159) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.avc_log_spoofing), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.avc_log_spoofing_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.avc_log_spoofing_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + lineHeight = 12.sp + ) + } + Switch( + checked = enableAvcLogSpoofing, + onCheckedChange = onEnableAvcLogSpoofingChange, + enabled = !isLoading + ) + } + } + } + + // 槽位信息按钮 + if (isAbDevice) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.susfs_slot_info_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Text( + text = stringResource(R.string.susfs_slot_info_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp + ) + + OutlinedButton( + onClick = onShowSlotInfo, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_slot_info_title), + fontWeight = FontWeight.Medium + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 备份按钮 + OutlinedButton( + onClick = onShowBackupDialog, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .weight(1f) + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.Backup, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_backup_title), + fontWeight = FontWeight.Medium + ) + } + // 还原按钮 + OutlinedButton( + onClick = onShowRestoreDialog, + enabled = !isLoading, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .weight(1f) + .height(40.dp) + ) { + Icon( + imageVector = Icons.Default.Restore, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + stringResource(R.string.susfs_restore_title), + fontWeight = FontWeight.Medium + ) + } + } + } +} + +/** + * 槽位信息对话框 + */ +@Composable +private fun SlotInfoDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + slotInfoList: List, + currentActiveSlot: String, + isLoadingSlotInfo: Boolean, + onRefresh: () -> Unit, + onUseUname: (String) -> Unit, + onUseBuildTime: (String) -> Unit +) { + val isAbDevice = produceState(initialValue = false) { + value = isAbDevice() + }.value + + if (showDialog && isAbDevice) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.susfs_slot_info_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.susfs_current_active_slot, currentActiveSlot), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + + if (slotInfoList.isNotEmpty()) { + slotInfoList.forEach { slotInfo -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (slotInfo.slotName == currentActiveSlot) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + ), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = null, + tint = if (slotInfo.slotName == currentActiveSlot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = slotInfo.slotName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (slotInfo.slotName == currentActiveSlot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + if (slotInfo.slotName == currentActiveSlot) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary + ) { + Text( + text = stringResource(R.string.susfs_slot_current_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + Text( + text = stringResource(R.string.susfs_slot_uname, slotInfo.uname), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.susfs_slot_build_time, slotInfo.buildTime), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { onUseUname(slotInfo.uname) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(6.dp) + ) { + Text(stringResource(R.string.susfs_slot_use_uname), fontSize = 12.sp) + } + Button( + onClick = { onUseBuildTime(slotInfo.buildTime) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(6.dp) + ) { + Text(stringResource(R.string.susfs_slot_use_build_time), fontSize = 12.sp) + } + } + } + } + } + } else { + Text( + text = stringResource(R.string.susfs_slot_info_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + Button( + onClick = onRefresh, + enabled = !isLoadingSlotInfo, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.refresh)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.close)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/component/SuSFSConfigDialogs.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/component/SuSFSConfigDialogs.kt new file mode 100644 index 0000000..41a0c4c --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/component/SuSFSConfigDialogs.kt @@ -0,0 +1,1733 @@ +package com.sukisu.ultra.ui.susfs.component + +import android.annotation.SuppressLint +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.util.Log +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.susfs.util.SuSFSManager +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import kotlinx.coroutines.launch + +/** + * 添加路径对话框 + */ +@Composable +fun AddPathDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, + isLoading: Boolean, + titleRes: Int, + labelRes: Int, + placeholderRes: Int, + initialValue: String = "" +) { + var newPath by remember { mutableStateOf("") } + + // 当对话框显示时,设置初始值 + LaunchedEffect(showDialog, initialValue) { + if (showDialog) { + newPath = initialValue + } + } + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(titleRes), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + OutlinedTextField( + value = newPath, + onValueChange = { newPath = it }, + label = { Text(stringResource(labelRes)) }, + placeholder = { Text(stringResource(placeholderRes)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) + }, + confirmButton = { + Button( + onClick = { + if (newPath.isNotBlank()) { + onConfirm(newPath.trim()) + newPath = "" + } + }, + enabled = newPath.isNotBlank() && !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(if (initialValue.isNotEmpty()) R.string.susfs_save else R.string.add)) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + newPath = "" + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } +} + +/** + * 快捷添加应用路径对话框 + */ +@Composable +fun AddAppPathDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit, + isLoading: Boolean, + apps: List = emptyList(), + onLoadApps: () -> Unit, + existingSusPaths: Set = emptySet() +) { + var searchText by remember { mutableStateOf("") } + var selectedApps by remember { mutableStateOf(setOf()) } + + // 获取已添加的包名 + val addedPackageNames = remember(existingSusPaths) { + existingSusPaths.mapNotNull { path -> + val regex = Regex(".*/Android/data/([^/]+)/?.*") + regex.find(path)?.groupValues?.get(1) + }.toSet() + } + + // 过滤掉已添加的应用 + val availableApps = remember(apps, addedPackageNames) { + apps.filter { app -> + !addedPackageNames.contains(app.packageName) + } + } + + val filteredApps = remember(availableApps, searchText) { + if (searchText.isBlank()) { + availableApps + } else { + availableApps.filter { app -> + app.appName.contains(searchText, ignoreCase = true) || + app.packageName.contains(searchText, ignoreCase = true) + } + } + } + + LaunchedEffect(showDialog) { + if (showDialog && apps.isEmpty()) { + onLoadApps() + } + // 当对话框显示时清空选择 + if (showDialog) { + selectedApps = setOf() + } + } + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.susfs_add_app_path), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + label = { Text(stringResource(R.string.search_apps)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) + + // 显示统计信息 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (selectedApps.isNotEmpty()) { + Text( + text = stringResource(R.string.selected_apps_count, selectedApps.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + if (addedPackageNames.isNotEmpty()) { + Text( + text = stringResource(R.string.already_added_apps_count, addedPackageNames.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (filteredApps.isEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + Text( + text = if (availableApps.isEmpty()) { + stringResource(R.string.all_apps_already_added) + } else { + stringResource(R.string.no_apps_found) + }, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.height(300.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredApps) { app -> + val isSelected = selectedApps.contains(app) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ), + onClick = { + selectedApps = if (isSelected) { + selectedApps - app + } else { + selectedApps + app + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 应用图标 + AppIcon( + packageName = app.packageName, + packageInfo = app.packageInfo, + modifier = Modifier.size(40.dp) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text( + text = app.appName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + + // 选择指示器 + if (isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } else { + Icon( + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + if (selectedApps.isNotEmpty()) { + onConfirm(selectedApps.map { it.packageName }) + } + selectedApps = setOf() + searchText = "" + }, + enabled = selectedApps.isNotEmpty() && !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = stringResource(R.string.add) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + selectedApps = setOf() + searchText = "" + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } +} + + +/** + * 应用图标组件 + */ +@Composable +fun AppIcon( + packageName: String, + packageInfo: PackageInfo? = null, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier +) { + val context = LocalContext.current + if (packageInfo != null) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(packageInfo) + .crossfade(true) + .build(), + contentDescription = null, + modifier = modifier.clip(RoundedCornerShape(8.dp)) + ) + } else { + var appIcon by remember(packageName) { + mutableStateOf( + AppInfoCache.getAppInfo(packageName)?.drawable + ) + } + + LaunchedEffect(packageName) { + if (appIcon == null && !AppInfoCache.hasCache(packageName)) { + try { + val packageManager = context.packageManager + val applicationInfo = packageManager.getApplicationInfo(packageName, 0) + val drawable = packageManager.getApplicationIcon(applicationInfo) + appIcon = drawable + val cachedInfo = AppInfoCache.CachedAppInfo( + appName = packageName, + packageInfo = null, + drawable = drawable + ) + AppInfoCache.putAppInfo(packageName, cachedInfo) + } catch (_: Exception) { + Log.d("获取应用图标失败", packageName) + } + } + } + Image( + painter = rememberDrawablePainter(appIcon), + contentDescription = null, + modifier = modifier.clip(RoundedCornerShape(8.dp)) + ) + } +} + + +/** + * 添加尝试卸载对话框 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTryUmountDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onConfirm: (String, Int) -> Unit, + isLoading: Boolean, + initialPath: String = "", + initialMode: Int = 0 +) { + var newUmountPath by remember { mutableStateOf("") } + var newUmountMode by remember { mutableIntStateOf(0) } + var umountModeExpanded by remember { mutableStateOf(false) } + + // 当对话框显示时,设置初始值 + LaunchedEffect(showDialog, initialPath, initialMode) { + if (showDialog) { + newUmountPath = initialPath + newUmountMode = initialMode + } + } + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(if (initialPath.isNotEmpty()) R.string.susfs_edit_try_umount else R.string.susfs_add_try_umount), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = newUmountPath, + onValueChange = { newUmountPath = it }, + label = { Text(stringResource(R.string.susfs_path_label)) }, + placeholder = { Text(stringResource(R.string.susfs_path_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) + + ExposedDropdownMenuBox( + expanded = umountModeExpanded, + onExpandedChange = { umountModeExpanded = !umountModeExpanded } + ) { + OutlinedTextField( + value = if (newUmountMode == 0) + stringResource(R.string.susfs_umount_mode_normal) + else + stringResource(R.string.susfs_umount_mode_detach), + onValueChange = { }, + readOnly = true, + label = { Text(stringResource(R.string.susfs_umount_mode_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = umountModeExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true), + shape = RoundedCornerShape(8.dp) + ) + ExposedDropdownMenu( + expanded = umountModeExpanded, + onDismissRequest = { umountModeExpanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.susfs_umount_mode_normal)) }, + onClick = { + newUmountMode = 0 + umountModeExpanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.susfs_umount_mode_detach)) }, + onClick = { + newUmountMode = 1 + umountModeExpanded = false + } + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + if (newUmountPath.isNotBlank()) { + onConfirm(newUmountPath.trim(), newUmountMode) + newUmountPath = "" + newUmountMode = 0 + } + }, + enabled = newUmountPath.isNotBlank() && !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(if (initialPath.isNotEmpty()) R.string.susfs_save else R.string.add)) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + newUmountPath = "" + newUmountMode = 0 + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } +} + +/** + * 添加Kstat静态配置对话框 + */ +@Composable +fun AddKstatStaticallyDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onConfirm: (String, String, String, String, String, String, String, String, String, String, String, String, String) -> Unit, + isLoading: Boolean, + initialConfig: String = "" +) { + var newKstatPath by remember { mutableStateOf("") } + var newKstatIno by remember { mutableStateOf("") } + var newKstatDev by remember { mutableStateOf("") } + var newKstatNlink by remember { mutableStateOf("") } + var newKstatSize by remember { mutableStateOf("") } + var newKstatAtime by remember { mutableStateOf("") } + var newKstatAtimeNsec by remember { mutableStateOf("") } + var newKstatMtime by remember { mutableStateOf("") } + var newKstatMtimeNsec by remember { mutableStateOf("") } + var newKstatCtime by remember { mutableStateOf("") } + var newKstatCtimeNsec by remember { mutableStateOf("") } + var newKstatBlocks by remember { mutableStateOf("") } + var newKstatBlksize by remember { mutableStateOf("") } + + // 当对话框显示时,解析初始配置 + LaunchedEffect(showDialog, initialConfig) { + if (showDialog && initialConfig.isNotEmpty()) { + val parts = initialConfig.split("|") + if (parts.size >= 13) { + newKstatPath = parts[0] + newKstatIno = if (parts[1] == "default") "" else parts[1] + newKstatDev = if (parts[2] == "default") "" else parts[2] + newKstatNlink = if (parts[3] == "default") "" else parts[3] + newKstatSize = if (parts[4] == "default") "" else parts[4] + newKstatAtime = if (parts[5] == "default") "" else parts[5] + newKstatAtimeNsec = if (parts[6] == "default") "" else parts[6] + newKstatMtime = if (parts[7] == "default") "" else parts[7] + newKstatMtimeNsec = if (parts[8] == "default") "" else parts[8] + newKstatCtime = if (parts[9] == "default") "" else parts[9] + newKstatCtimeNsec = if (parts[10] == "default") "" else parts[10] + newKstatBlocks = if (parts[11] == "default") "" else parts[11] + newKstatBlksize = if (parts[12] == "default") "" else parts[12] + } + } else if (showDialog && initialConfig.isEmpty()) { + // 清空所有字段 + newKstatPath = "" + newKstatIno = "" + newKstatDev = "" + newKstatNlink = "" + newKstatSize = "" + newKstatAtime = "" + newKstatAtimeNsec = "" + newKstatMtime = "" + newKstatMtimeNsec = "" + newKstatCtime = "" + newKstatCtimeNsec = "" + newKstatBlocks = "" + newKstatBlksize = "" + } + } + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(if (initialConfig.isNotEmpty()) R.string.edit_kstat_statically_title else R.string.add_kstat_statically_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatPath, + onValueChange = { newKstatPath = it }, + label = { Text(stringResource(R.string.file_or_directory_path_label)) }, + placeholder = { Text("/path/to/file_or_directory") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatIno, + onValueChange = { newKstatIno = it }, + label = { Text("ino") }, + placeholder = { Text("1234") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + OutlinedTextField( + value = newKstatDev, + onValueChange = { newKstatDev = it }, + label = { Text("dev") }, + placeholder = { Text("1234") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatNlink, + onValueChange = { newKstatNlink = it }, + label = { Text("nlink") }, + placeholder = { Text("2") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + OutlinedTextField( + value = newKstatSize, + onValueChange = { newKstatSize = it }, + label = { Text("size") }, + placeholder = { Text("223344") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatAtime, + onValueChange = { newKstatAtime = it }, + label = { Text("atime") }, + placeholder = { Text("1712592355") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + OutlinedTextField( + value = newKstatAtimeNsec, + onValueChange = { newKstatAtimeNsec = it }, + label = { Text("atime_nsec") }, + placeholder = { Text("0") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatMtime, + onValueChange = { newKstatMtime = it }, + label = { Text("mtime") }, + placeholder = { Text("1712592355") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + OutlinedTextField( + value = newKstatMtimeNsec, + onValueChange = { newKstatMtimeNsec = it }, + label = { Text("mtime_nsec") }, + placeholder = { Text("0") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatCtime, + onValueChange = { newKstatCtime = it }, + label = { Text("ctime") }, + placeholder = { Text("1712592355") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + OutlinedTextField( + value = newKstatCtimeNsec, + onValueChange = { newKstatCtimeNsec = it }, + label = { Text("ctime_nsec") }, + placeholder = { Text("0") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = newKstatBlocks, + onValueChange = { newKstatBlocks = it }, + label = { Text("blocks") }, + placeholder = { Text("16") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + OutlinedTextField( + value = newKstatBlksize, + onValueChange = { newKstatBlksize = it }, + label = { Text("blksize") }, + placeholder = { Text("512") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) + } + + Text( + text = stringResource(R.string.hint_use_default_value), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + Button( + onClick = { + if (newKstatPath.isNotBlank()) { + onConfirm( + newKstatPath.trim(), + newKstatIno.trim().ifBlank { "default" }, + newKstatDev.trim().ifBlank { "default" }, + newKstatNlink.trim().ifBlank { "default" }, + newKstatSize.trim().ifBlank { "default" }, + newKstatAtime.trim().ifBlank { "default" }, + newKstatAtimeNsec.trim().ifBlank { "default" }, + newKstatMtime.trim().ifBlank { "default" }, + newKstatMtimeNsec.trim().ifBlank { "default" }, + newKstatCtime.trim().ifBlank { "default" }, + newKstatCtimeNsec.trim().ifBlank { "default" }, + newKstatBlocks.trim().ifBlank { "default" }, + newKstatBlksize.trim().ifBlank { "default" } + ) + // 清空所有字段 + newKstatPath = "" + newKstatIno = "" + newKstatDev = "" + newKstatNlink = "" + newKstatSize = "" + newKstatAtime = "" + newKstatAtimeNsec = "" + newKstatMtime = "" + newKstatMtimeNsec = "" + newKstatCtime = "" + newKstatCtimeNsec = "" + newKstatBlocks = "" + newKstatBlksize = "" + } + }, + enabled = newKstatPath.isNotBlank() && !isLoading, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(if (initialConfig.isNotEmpty()) R.string.susfs_save else R.string.add)) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + // 清空所有字段 + newKstatPath = "" + newKstatIno = "" + newKstatDev = "" + newKstatNlink = "" + newKstatSize = "" + newKstatAtime = "" + newKstatAtimeNsec = "" + newKstatMtime = "" + newKstatMtimeNsec = "" + newKstatCtime = "" + newKstatCtimeNsec = "" + newKstatBlocks = "" + newKstatBlksize = "" + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } +} + +/** + * 确认对话框 + */ +@Composable +fun ConfirmDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + titleRes: Int, + messageRes: Int, + isLoading: Boolean = false, + isDestructive: Boolean = false +) { + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { Text(stringResource(messageRes)) }, + confirmButton = { + Button( + onClick = onConfirm, + enabled = !isLoading, + colors = if (isDestructive) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + } else { + ButtonDefaults.buttonColors() + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } +} + +// 应用信息缓存 +object AppInfoCache { + private val appInfoMap = mutableMapOf() + + data class CachedAppInfo( + val appName: String, + val packageInfo: PackageInfo?, + val drawable: Drawable?, + val timestamp: Long = System.currentTimeMillis() + ) + + fun getAppInfo(packageName: String): CachedAppInfo? { + return appInfoMap[packageName] + } + + fun putAppInfo(packageName: String, appInfo: CachedAppInfo) { + appInfoMap[packageName] = appInfo + } + + fun clearCache() { + appInfoMap.clear() + } + + fun hasCache(packageName: String): Boolean { + return appInfoMap.containsKey(packageName) + } + + fun getAppInfoFromSuperUser(packageName: String): CachedAppInfo? { + val superUserApp = SuperUserViewModel.apps.find { it.packageName == packageName } + return superUserApp?.let { app -> + CachedAppInfo( + appName = app.label, + packageInfo = app.packageInfo, + drawable = null + ) + } + } +} + +/** + * 空状态显示组件 + */ +@Composable +fun EmptyStateCard( + message: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +/** + * 路径项目卡片组件 + */ +@Composable +fun PathItemCard( + path: String, + icon: ImageVector, + onDelete: () -> Unit, + onEdit: (() -> Unit)? = null, + isLoading: Boolean = false, + additionalInfo: String? = null +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 1.dp), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = path, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + if (additionalInfo != null) { + Text( + text = additionalInfo, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (onEdit != null) { + IconButton( + onClick = onEdit, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.edit), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + } + IconButton( + onClick = onDelete, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + +/** + * Kstat配置项目卡片组件 + */ +@Composable +fun KstatConfigItemCard( + config: String, + onDelete: () -> Unit, + onEdit: (() -> Unit)? = null, + isLoading: Boolean = false +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 1.dp), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + val parts = config.split("|") + if (parts.isNotEmpty()) { + Text( + text = parts[0], // 路径 + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + if (parts.size > 1) { + Text( + text = parts.drop(1).joinToString(" "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Text( + text = config, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (onEdit != null) { + IconButton( + onClick = onEdit, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.edit), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + } + IconButton( + onClick = onDelete, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + +/** + * Add Kstat路径项目卡片组件 + */ +@Composable +fun AddKstatPathItemCard( + path: String, + onDelete: () -> Unit, + onEdit: (() -> Unit)? = null, + onUpdate: () -> Unit, + onUpdateFullClone: () -> Unit, + isLoading: Boolean = false +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 1.dp), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = path, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (onEdit != null) { + IconButton( + onClick = onEdit, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.edit), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + } + IconButton( + onClick = onUpdate, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Update, + contentDescription = stringResource(R.string.update), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + IconButton( + onClick = onUpdateFullClone, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.susfs_update_full_clone), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(16.dp) + ) + } + IconButton( + onClick = onDelete, + enabled = !isLoading, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + +/** + * 启用功能状态卡片组件 + */ +@Composable +fun FeatureStatusCard( + feature: SuSFSManager.EnabledFeature, + onRefresh: (() -> Unit)? = null, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + // 日志配置对话框状态 + var showLogConfigDialog by remember { mutableStateOf(false) } + var logEnabled by remember { mutableStateOf(SuSFSManager.getEnableLogState(context)) } + + // 日志配置对话框 + if (showLogConfigDialog) { + AlertDialog( + onDismissRequest = { showLogConfigDialog = false }, + title = { + Text( + text = stringResource(R.string.susfs_log_config_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.susfs_log_config_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.susfs_enable_log_label), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Switch( + checked = logEnabled, + onCheckedChange = { logEnabled = it } + ) + } + } + }, + confirmButton = { + Button( + onClick = { + coroutineScope.launch { + if (SuSFSManager.setEnableLog(context, logEnabled)) { + onRefresh?.invoke() + } + showLogConfigDialog = false + } + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.susfs_apply)) + } + }, + dismissButton = { + TextButton( + onClick = { + // 恢复原始状态 + logEnabled = SuSFSManager.getEnableLogState(context) + showLogConfigDialog = false + }, + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(12.dp) + ) + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 1.dp) + .then( + if (feature.canConfigure) { + Modifier.clickable { + // 更新当前状态 + logEnabled = SuSFSManager.getEnableLogState(context) + showLogConfigDialog = true + } + } else { + Modifier + } + ), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = feature.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + if (feature.canConfigure) { + Text( + text = stringResource(R.string.susfs_feature_configurable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 状态标签 + Surface( + shape = RoundedCornerShape(6.dp), + color = when { + feature.isEnabled -> MaterialTheme.colorScheme.primary + else -> Color.Gray + } + ) { + Text( + text = feature.statusText, + style = MaterialTheme.typography.labelLarge, + color = when { + feature.isEnabled -> MaterialTheme.colorScheme.onPrimary + else -> Color.White + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + } + } + } +} + +/** + * SUS挂载隐藏控制卡片组件 + */ +@Composable +fun SusMountHidingControlCard( + hideSusMountsForAllProcs: Boolean, + isLoading: Boolean, + onToggleHiding: (Boolean) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 标题行 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (hideSusMountsForAllProcs) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.susfs_hide_mounts_control_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + // 描述文本 + Text( + text = stringResource(R.string.susfs_hide_mounts_control_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 16.sp + ) + + // 控制开关行 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.susfs_hide_mounts_for_all_procs_label), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (hideSusMountsForAllProcs) { + stringResource(R.string.susfs_hide_mounts_for_all_procs_enabled_description) + } else { + stringResource(R.string.susfs_hide_mounts_for_all_procs_disabled_description) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp + ) + } + Switch( + checked = hideSusMountsForAllProcs, + onCheckedChange = onToggleHiding, + enabled = !isLoading + ) + } + + // 当前设置显示 + Text( + text = stringResource( + R.string.susfs_hide_mounts_current_setting, + if (hideSusMountsForAllProcs) { + stringResource(R.string.susfs_hide_mounts_setting_all) + } else { + stringResource(R.string.susfs_hide_mounts_setting_non_ksu) + } + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + + // 建议文本 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = stringResource(R.string.susfs_hide_mounts_recommendation), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp, + modifier = Modifier.padding(12.dp) + ) + } + } + } +} + +/** + * 应用路径分组卡片 + */ +@Composable +fun AppPathGroupCard( + packageName: String, + paths: List, + onDeleteGroup: () -> Unit, + onEditGroup: (() -> Unit)? = null, + isLoading: Boolean +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val superUserApps = SuperUserViewModel.apps + var cachedAppInfo by remember(packageName, superUserApps.size) { + mutableStateOf(AppInfoCache.getAppInfo(packageName)) + } + var isLoadingAppInfo by remember(packageName, superUserApps.size) { mutableStateOf(false) } + + LaunchedEffect(packageName, superUserApps.size) { + if (cachedAppInfo == null || superUserApps.isNotEmpty()) { + isLoadingAppInfo = true + coroutineScope.launch { + try { + val superUserAppInfo = AppInfoCache.getAppInfoFromSuperUser(packageName) + + if (superUserAppInfo != null) { + val packageManager = context.packageManager + val drawable = try { + superUserAppInfo.packageInfo?.applicationInfo?.let { + packageManager.getApplicationIcon(it) + } + } catch (_: Exception) { + null + } + + val newCachedInfo = AppInfoCache.CachedAppInfo( + appName = superUserAppInfo.appName, + packageInfo = superUserAppInfo.packageInfo, + drawable = drawable + ) + + AppInfoCache.putAppInfo(packageName, newCachedInfo) + cachedAppInfo = newCachedInfo + } else { + val packageManager = context.packageManager + val appInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA) + + val appName = try { + appInfo.applicationInfo?.let { + packageManager.getApplicationLabel(it).toString() + } ?: packageName + } catch (_: Exception) { + packageName + } + + val drawable = try { + appInfo.applicationInfo?.let { + packageManager.getApplicationIcon(it) + } + } catch (_: Exception) { + null + } + + val newCachedInfo = AppInfoCache.CachedAppInfo( + appName = appName, + packageInfo = appInfo, + drawable = drawable + ) + + AppInfoCache.putAppInfo(packageName, newCachedInfo) + cachedAppInfo = newCachedInfo + } + } catch (_: Exception) { + val newCachedInfo = AppInfoCache.CachedAppInfo( + appName = packageName, + packageInfo = null, + drawable = null + ) + AppInfoCache.putAppInfo(packageName, newCachedInfo) + cachedAppInfo = newCachedInfo + } finally { + isLoadingAppInfo = false + } + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // 应用图标 + AppIcon( + packageName = packageName, + packageInfo = cachedAppInfo?.packageInfo, + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + val displayName = cachedAppInfo?.appName?.ifEmpty { packageName } ?: packageName + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + if (!isLoadingAppInfo && cachedAppInfo?.appName?.isNotEmpty() == true && + cachedAppInfo?.appName != packageName) { + Text( + text = packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (onEditGroup != null) { + IconButton( + onClick = onEditGroup, + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.edit), + tint = MaterialTheme.colorScheme.primary + ) + } + } + IconButton( + onClick = onDeleteGroup, + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + + // 显示所有路径 + Spacer(modifier = Modifier.height(8.dp)) + + paths.forEach { path -> + Text( + text = path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(6.dp) + ) + .padding(8.dp) + ) + + if (path != paths.last()) { + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + } +} + +/** + * 分组标题组件 + */ +@Composable +fun SectionHeader( + title: String, + subtitle: String?, + icon: ImageVector, + count: Int +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary + ) { + Text( + text = count.toString(), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold + ) + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/component/SuSFSConfigTabs.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/component/SuSFSConfigTabs.kt new file mode 100644 index 0000000..be683f2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/component/SuSFSConfigTabs.kt @@ -0,0 +1,928 @@ +package com.sukisu.ultra.ui.susfs.component + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.susfs.util.SuSFSManager +import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion158 +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel + +/** + * SUS路径内容组件 + */ +@Composable +fun SusPathsContent( + susPaths: Set, + isLoading: Boolean, + onAddPath: () -> Unit, + onAddAppPath: () -> Unit, + onRemovePath: (String) -> Unit, + onEditPath: ((String) -> Unit)? = null, + forceRefreshApps: Boolean = false +) { + val superUserApps = SuperUserViewModel.apps + val superUserIsRefreshing = remember { SuperUserViewModel().isRefreshing } + + LaunchedEffect(superUserIsRefreshing, superUserApps.size) { + if (!superUserIsRefreshing && superUserApps.isNotEmpty()) { + AppInfoCache.clearCache() + } + } + + LaunchedEffect(forceRefreshApps) { + if (forceRefreshApps) { + AppInfoCache.clearCache() + } + } + + val (appPathGroups, otherPaths) = remember(susPaths) { + val appPathRegex = Regex(".*/Android/data/([^/]+)/?.*") + val uidPathRegex = Regex("/sys/fs/cgroup/uid_([0-9]+)") + val appPathMap = mutableMapOf>() + val uidToPackageMap = mutableMapOf() + val others = mutableListOf() + + // 构建UID到包名的映射 + SuperUserViewModel.apps.forEach { app -> + try { + val uid = app.packageInfo.applicationInfo?.uid + uidToPackageMap[uid.toString()] = app.packageName + } catch (_: Exception) { + } + } + + susPaths.forEach { path -> + val appDataMatch = appPathRegex.find(path) + val uidMatch = uidPathRegex.find(path) + + when { + appDataMatch != null -> { + val packageName = appDataMatch.groupValues[1] + appPathMap.getOrPut(packageName) { mutableListOf() }.add(path) + } + uidMatch != null -> { + val uid = uidMatch.groupValues[1] + val packageName = uidToPackageMap[uid] + if (packageName != null) { + appPathMap.getOrPut(packageName) { mutableListOf() }.add(path) + } else { + others.add(path) + } + } + else -> { + others.add(path) + } + } + } + + val sortedAppGroups = appPathMap.toList() + .sortedBy { it.first } + .map { (packageName, paths) -> packageName to paths.sorted() } + + Pair(sortedAppGroups, others.sorted()) + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 应用路径分组 + if (appPathGroups.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(R.string.app_paths_section), + subtitle = null, + icon = Icons.Default.Apps, + count = appPathGroups.size + ) + } + + items(appPathGroups) { (packageName, paths) -> + AppPathGroupCard( + packageName = packageName, + paths = paths, + onDeleteGroup = { + paths.forEach { path -> onRemovePath(path) } + }, + onEditGroup = if (onEditPath != null) { + { + onEditPath(paths.first()) + } + } else null, + isLoading = isLoading + ) + } + } + + // 其他路径 + if (otherPaths.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(R.string.other_paths_section), + subtitle = null, + icon = Icons.Default.Folder, + count = otherPaths.size + ) + } + + items(otherPaths) { path -> + PathItemCard( + path = path, + icon = Icons.Default.Folder, + onDelete = { onRemovePath(path) }, + onEdit = if (onEditPath != null) { { onEditPath(path) } } else null, + isLoading = isLoading + ) + } + } + + if (susPaths.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.susfs_no_paths_configured) + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onAddPath, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add_custom_path)) + } + + Button( + onClick = onAddAppPath, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Apps, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add_app_path)) + } + } + } + } + } +} + +/** + * SUS循环路径内容组件 + */ +@Composable +fun SusLoopPathsContent( + susLoopPaths: Set, + isLoading: Boolean, + onAddLoopPath: () -> Unit, + onRemoveLoopPath: (String) -> Unit, + onEditLoopPath: ((String) -> Unit)? = null +) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 说明卡片 + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.sus_loop_paths_description_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.sus_loop_paths_description_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.susfs_loop_path_restriction_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + + if (susLoopPaths.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.susfs_no_loop_paths_configured) + ) + } + } else { + item { + SectionHeader( + title = stringResource(R.string.loop_paths_section), + subtitle = null, + icon = Icons.Default.Loop, + count = susLoopPaths.size + ) + } + + items(susLoopPaths.toList()) { path -> + PathItemCard( + path = path, + icon = Icons.Default.Loop, + onDelete = { onRemoveLoopPath(path) }, + onEdit = if (onEditLoopPath != null) { { onEditLoopPath(path) } } else null, + isLoading = isLoading + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onAddLoopPath, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add_loop_path)) + } + } + } + } + } +} + +/** + * SUS Maps内容组件 + */ +@Composable +fun SusMapsContent( + susMaps: Set, + isLoading: Boolean, + onAddSusMap: () -> Unit, + onRemoveSusMap: (String) -> Unit, + onEditSusMap: ((String) -> Unit)? = null +) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 说明卡片 + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.sus_maps_description_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.sus_maps_description_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.sus_maps_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + Text( + text = stringResource(R.string.sus_maps_debug_info), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + + if (susMaps.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.susfs_no_sus_maps_configured) + ) + } + } else { + item { + SectionHeader( + title = stringResource(R.string.sus_maps_section), + subtitle = null, + icon = Icons.Default.Security, + count = susMaps.size + ) + } + + items(susMaps.toList()) { map -> + PathItemCard( + path = map, + icon = Icons.Default.Security, + onDelete = { onRemoveSusMap(map) }, + onEdit = if (onEditSusMap != null) { { onEditSusMap(map) } } else null, + isLoading = isLoading + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onAddSusMap, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add)) + } + } + } + } + } +} + +/** + * SUS挂载内容组件 + */ +@Composable +fun SusMountsContent( + susMounts: Set, + hideSusMountsForAllProcs: Boolean, + isSusVersion158: Boolean, + isLoading: Boolean, + onAddMount: () -> Unit, + onRemoveMount: (String) -> Unit, + onEditMount: ((String) -> Unit)? = null, + onToggleHideSusMountsForAllProcs: (Boolean) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isSusVersion158) { + item { + SusMountHidingControlCard( + hideSusMountsForAllProcs = hideSusMountsForAllProcs, + isLoading = isLoading, + onToggleHiding = onToggleHideSusMountsForAllProcs + ) + } + } + + if (susMounts.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.susfs_no_mounts_configured) + ) + } + } else { + items(susMounts.toList()) { mount -> + PathItemCard( + path = mount, + icon = Icons.Default.Storage, + onDelete = { onRemoveMount(mount) }, + onEdit = if (onEditMount != null) { { onEditMount(mount) } } else null, + isLoading = isLoading + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onAddMount, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add)) + } + } + } + } + } +} + +/** + * 尝试卸载内容组件 + */ +@Composable +fun TryUmountContent( + tryUmounts: Set, + umountForZygoteIsoService: Boolean, + isLoading: Boolean, + onAddUmount: () -> Unit, + onRemoveUmount: (String) -> Unit, + onEditUmount: ((String) -> Unit)? = null, + onToggleUmountForZygoteIsoService: (Boolean) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isSusVersion158()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.umount_zygote_iso_service), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.umount_zygote_iso_service_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp + ) + } + Switch( + checked = umountForZygoteIsoService, + onCheckedChange = onToggleUmountForZygoteIsoService, + enabled = !isLoading + ) + } + } + } + } + + if (tryUmounts.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.susfs_no_umounts_configured) + ) + } + } else { + items(tryUmounts.toList()) { umountEntry -> + val parts = umountEntry.split("|") + val path = if (parts.isNotEmpty()) parts[0] else umountEntry + val mode = if (parts.size > 1) parts[1] else "0" + val modeText = if (mode == "0") + stringResource(R.string.susfs_umount_mode_normal_short) + else + stringResource(R.string.susfs_umount_mode_detach_short) + + PathItemCard( + path = path, + icon = Icons.Default.Storage, + additionalInfo = stringResource(R.string.susfs_umount_mode_display, modeText, mode), + onDelete = { onRemoveUmount(umountEntry) }, + onEdit = if (onEditUmount != null) { { onEditUmount(umountEntry) } } else null, + isLoading = isLoading + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onAddUmount, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add)) + } + } + } + } + } +} + +/** + * Kstat配置内容组件 + */ +@Composable +fun KstatConfigContent( + kstatConfigs: Set, + addKstatPaths: Set, + isLoading: Boolean, + onAddKstatStatically: () -> Unit, + onAddKstat: () -> Unit, + onRemoveKstatConfig: (String) -> Unit, + onEditKstatConfig: ((String) -> Unit)? = null, + onRemoveAddKstat: (String) -> Unit, + onEditAddKstat: ((String) -> Unit)? = null, + onUpdateKstat: (String) -> Unit, + onUpdateKstatFullClone: (String) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.kstat_config_description_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.kstat_config_description_add_statically), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.kstat_config_description_add), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.kstat_config_description_update), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.kstat_config_description_update_full_clone), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (kstatConfigs.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.static_kstat_config), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + items(kstatConfigs.toList()) { config -> + KstatConfigItemCard( + config = config, + onDelete = { onRemoveKstatConfig(config) }, + onEdit = if (onEditKstatConfig != null) { { onEditKstatConfig(config) } } else null, + isLoading = isLoading + ) + } + } + + if (addKstatPaths.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.kstat_path_management), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + items(addKstatPaths.toList()) { path -> + AddKstatPathItemCard( + path = path, + onDelete = { onRemoveAddKstat(path) }, + onEdit = if (onEditAddKstat != null) { { onEditAddKstat(path) } } else null, + onUpdate = { onUpdateKstat(path) }, + onUpdateFullClone = { onUpdateKstatFullClone(path) }, + isLoading = isLoading + ) + } + } + + if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.no_kstat_config_message) + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onAddKstat, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add)) + } + + Button( + onClick = onAddKstatStatically, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.add)) + } + } + } + } + } +} + +/** + * 路径设置内容组件 + */ +@SuppressLint("SdCardPath") +@Composable +fun PathSettingsContent( + androidDataPath: String, + onAndroidDataPathChange: (String) -> Unit, + sdcardPath: String, + onSdcardPathChange: (String) -> Unit, + isLoading: Boolean, + onSetAndroidDataPath: () -> Unit, + onSetSdcardPath: () -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = androidDataPath, + onValueChange = onAndroidDataPathChange, + label = { Text(stringResource(R.string.susfs_android_data_path_label)) }, + placeholder = { Text("/sdcard/Android/data") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + Button( + onClick = onSetAndroidDataPath, + enabled = !isLoading && androidDataPath.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.susfs_set_android_data_path)) + } + } + } + } + + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = sdcardPath, + onValueChange = onSdcardPathChange, + label = { Text(stringResource(R.string.susfs_sdcard_path_label)) }, + placeholder = { Text("/sdcard") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + Button( + onClick = onSetSdcardPath, + enabled = !isLoading && sdcardPath.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.susfs_set_sdcard_path)) + } + } + } + } + } +} + +/** + * 启用功能状态内容组件 + */ +@Composable +fun EnabledFeaturesContent( + enabledFeatures: List, + onRefresh: () -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.susfs_enabled_features_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + if (enabledFeatures.isEmpty()) { + item { + EmptyStateCard( + message = stringResource(R.string.susfs_no_features_found) + ) + } + } else { + items(enabledFeatures) { feature -> + FeatureStatusCard( + feature = feature, + onRefresh = onRefresh + ) + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/util/SuSFSManager.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/util/SuSFSManager.kt new file mode 100644 index 0000000..e199568 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/util/SuSFSManager.kt @@ -0,0 +1,1520 @@ +package com.sukisu.ultra.ui.susfs.util + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.os.Build +import android.util.Log +import android.widget.Toast +import com.dergoogler.mmrl.platform.Platform.Companion.context +import com.sukisu.ultra.R +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import androidx.core.content.edit +import com.sukisu.ultra.ui.util.getRootShell +import com.sukisu.ultra.ui.util.getSuSFSVersion +import com.sukisu.ultra.ui.util.getSuSFSFeatures +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/** + * SuSFS 配置管理器 + * 用于管理SuSFS相关的配置和命令执行 + */ +object SuSFSManager { + private const val PREFS_NAME = "susfs_config" + private const val KEY_UNAME_VALUE = "uname_value" + private const val KEY_BUILD_TIME_VALUE = "build_time_value" + private const val KEY_AUTO_START_ENABLED = "auto_start_enabled" + private const val KEY_SUS_PATHS = "sus_paths" + private const val KEY_SUS_LOOP_PATHS = "sus_loop_paths" + + private const val KEY_SUS_MAPS = "sus_maps" + private const val KEY_SUS_MOUNTS = "sus_mounts" + private const val KEY_TRY_UMOUNTS = "try_umounts" + private const val KEY_ANDROID_DATA_PATH = "android_data_path" + private const val KEY_SDCARD_PATH = "sdcard_path" + private const val KEY_ENABLE_LOG = "enable_log" + private const val KEY_EXECUTE_IN_POST_FS_DATA = "execute_in_post_fs_data" + private const val KEY_KSTAT_CONFIGS = "kstat_configs" + private const val KEY_ADD_KSTAT_PATHS = "add_kstat_paths" + private const val KEY_HIDE_SUS_MOUNTS_FOR_ALL_PROCS = "hide_sus_mounts_for_all_procs" + private const val KEY_ENABLE_CLEANUP_RESIDUE = "enable_cleanup_residue" + private const val KEY_ENABLE_HIDE_BL = "enable_hide_bl" + private const val KEY_UMOUNT_FOR_ZYGOTE_ISO_SERVICE = "umount_for_zygote_iso_service" + private const val KEY_ENABLE_AVC_LOG_SPOOFING = "enable_avc_log_spoofing" + + + // 常量 + private const val SUSFS_BINARY_TARGET_NAME = "ksu_susfs" + private const val DEFAULT_UNAME = "default" + private const val DEFAULT_BUILD_TIME = "default" + private const val MODULE_ID = "susfs_manager" + private const val MODULE_PATH = "/data/adb/modules/$MODULE_ID" + private const val MIN_VERSION_FOR_HIDE_MOUNT = "1.5.8" + private const val MIN_VERSION_FOR_LOOP_PATH = "1.5.9" + private const val MIN_VERSION_SUS_MAPS = "1.5.12" + const val MAX_SUSFS_VERSION = "2.0.0" + private const val BACKUP_FILE_EXTENSION = ".susfs_backup" + private const val MEDIA_DATA_PATH = "/data/media/0/Android/data" + private const val CGROUP_UID_PATH_PREFIX = "/sys/fs/cgroup/uid_" + + data class SlotInfo(val slotName: String, val uname: String, val buildTime: String) + data class CommandResult(val isSuccess: Boolean, val output: String, val errorOutput: String = "") + data class EnabledFeature( + val name: String, + val isEnabled: Boolean, + val statusText: String = if (isEnabled) context.getString(R.string.susfs_feature_enabled) else context.getString(R.string.susfs_feature_disabled), + val canConfigure: Boolean = false + ) + + /** + * 应用信息数据类 + */ + data class AppInfo( + val packageName: String, + val appName: String, + val packageInfo: PackageInfo, + val isSystemApp: Boolean + ) + + /** + * 备份数据类 + */ + data class BackupData( + val version: String, + val timestamp: Long, + val deviceInfo: String, + val configurations: Map + ) { + fun toJson(): String { + val jsonObject = JSONObject().apply { + put("version", version) + put("timestamp", timestamp) + put("deviceInfo", deviceInfo) + put("configurations", JSONObject(configurations)) + } + return jsonObject.toString(2) + } + + companion object { + fun fromJson(jsonString: String): BackupData? { + return try { + val jsonObject = JSONObject(jsonString) + val configurationsJson = jsonObject.getJSONObject("configurations") + val configurations = mutableMapOf() + + configurationsJson.keys().forEach { key -> + val value = configurationsJson.get(key) + configurations[key] = when (value) { + is JSONArray -> { + val set = mutableSetOf() + for (i in 0 until value.length()) { + set.add(value.getString(i)) + } + set + } + else -> value + } + } + + BackupData( + version = jsonObject.getString("version"), + timestamp = jsonObject.getLong("timestamp"), + deviceInfo = jsonObject.getString("deviceInfo"), + configurations = configurations + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + } + + /** + * 模块配置数据类 + */ + data class ModuleConfig( + val targetPath: String, + val unameValue: String, + val buildTimeValue: String, + val executeInPostFsData: Boolean, + val susPaths: Set, + val susLoopPaths: Set, + val susMaps: Set, + val susMounts: Set, + val tryUmounts: Set, + val androidDataPath: String, + val sdcardPath: String, + val enableLog: Boolean, + val kstatConfigs: Set, + val addKstatPaths: Set, + val hideSusMountsForAllProcs: Boolean, + val support158: Boolean, + val enableHideBl: Boolean, + val enableCleanupResidue: Boolean, + val umountForZygoteIsoService: Boolean, + val enableAvcLogSpoofing: Boolean + ) { + /** + * 检查是否有需要自启动的配置 + */ + fun hasAutoStartConfig(): Boolean { + return unameValue != DEFAULT_UNAME || + buildTimeValue != DEFAULT_BUILD_TIME || + susPaths.isNotEmpty() || + susLoopPaths.isNotEmpty() || + susMaps.isNotEmpty() || + susMounts.isNotEmpty() || + tryUmounts.isNotEmpty() || + kstatConfigs.isNotEmpty() || + addKstatPaths.isNotEmpty() + } + } + + // 基础工具方法 + private fun getPrefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private fun getSuSFSVersionUse(context: Context): String = try { + val version = getSuSFSVersion() + val binaryName = "${SUSFS_BINARY_TARGET_NAME}_${version.removePrefix("v")}" + if (isBinaryAvailable(context, binaryName)) { + version + } else { + MAX_SUSFS_VERSION + } + } catch (_: Exception) { + MAX_SUSFS_VERSION + } + + fun isBinaryAvailable(context: Context, binaryName: String): Boolean = try { + context.assets.open(binaryName).use { true } + } catch (_: IOException) { false } + + private fun getSuSFSBinaryName(context: Context): String = "${SUSFS_BINARY_TARGET_NAME}_${getSuSFSVersionUse(context).removePrefix("v")}" + + private fun getSuSFSTargetPath(): String = "/data/adb/ksu/bin/$SUSFS_BINARY_TARGET_NAME" + + private fun runCmd(shell: Shell, cmd: String): String { + return shell.newJob() + .add(cmd) + .to(mutableListOf(), null) + .exec().out + .joinToString("\n") + } + + private fun runCmdWithResult(cmd: String): CommandResult { + val result = Shell.getShell().newJob().add(cmd).exec() + return CommandResult(result.isSuccess, result.out.joinToString("\n"), result.err.joinToString("\n")) + } + + /** + * 版本比较方法 + */ + private fun compareVersions(version1: String, version2: String): Int { + val v1Parts = version1.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + val v2Parts = version2.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + + val maxLength = maxOf(v1Parts.size, v2Parts.size) + + for (i in 0 until maxLength) { + val v1Part = v1Parts.getOrNull(i) ?: 0 + val v2Part = v2Parts.getOrNull(i) ?: 0 + + when { + v1Part > v2Part -> return 1 + v1Part < v2Part -> return -1 + } + } + return 0 + } + + private fun isVersionAtLeast(minVersion: String): Boolean = try { + compareVersions(getSuSFSVersion(), minVersion) >= 0 + } catch (_: Exception) { + true + } + // 检查是否支持设置sdcard路径等功能(1.5.8+) + fun isSusVersion158(): Boolean = isVersionAtLeast(MIN_VERSION_FOR_HIDE_MOUNT) + + // 检查是否支持循环路径和AVC日志欺骗等功能(1.5.9+) + fun isSusVersion159(): Boolean = isVersionAtLeast(MIN_VERSION_FOR_LOOP_PATH) + + // 检查是否支持隐藏内存映射(1.5.12+) + fun isSusVersion1512(): Boolean = isVersionAtLeast(MIN_VERSION_SUS_MAPS) + + /** + * 获取当前模块配置 + */ + private fun getCurrentModuleConfig(context: Context): ModuleConfig { + return ModuleConfig( + targetPath = getSuSFSTargetPath(), + unameValue = getUnameValue(context), + buildTimeValue = getBuildTimeValue(context), + executeInPostFsData = getExecuteInPostFsData(context), + susPaths = getSusPaths(context), + susLoopPaths = getSusLoopPaths(context), + susMaps = getSusMaps(context), + susMounts = getSusMounts(context), + tryUmounts = getTryUmounts(context), + androidDataPath = getAndroidDataPath(context), + sdcardPath = getSdcardPath(context), + enableLog = getEnableLogState(context), + kstatConfigs = getKstatConfigs(context), + addKstatPaths = getAddKstatPaths(context), + hideSusMountsForAllProcs = getHideSusMountsForAllProcs(context), + support158 = isSusVersion158(), + enableHideBl = getEnableHideBl(context), + enableCleanupResidue = getEnableCleanupResidue(context), + umountForZygoteIsoService = getUmountForZygoteIsoService(context), + enableAvcLogSpoofing = getEnableAvcLogSpoofing(context) + ) + } + + // 配置存取方法 + fun saveUnameValue(context: Context, value: String) = + getPrefs(context).edit { putString(KEY_UNAME_VALUE, value) } + + fun getUnameValue(context: Context): String = + getPrefs(context).getString(KEY_UNAME_VALUE, DEFAULT_UNAME) ?: DEFAULT_UNAME + + fun saveBuildTimeValue(context: Context, value: String) = + getPrefs(context).edit { putString(KEY_BUILD_TIME_VALUE, value)} + + fun getBuildTimeValue(context: Context): String = + getPrefs(context).getString(KEY_BUILD_TIME_VALUE, DEFAULT_BUILD_TIME) ?: DEFAULT_BUILD_TIME + + fun setAutoStartEnabled(context: Context, enabled: Boolean) = + getPrefs(context).edit { putBoolean(KEY_AUTO_START_ENABLED, enabled) } + + fun isAutoStartEnabled(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_AUTO_START_ENABLED, false) + + fun saveEnableLogState(context: Context, enabled: Boolean) = + getPrefs(context).edit { putBoolean(KEY_ENABLE_LOG, enabled) } + + fun getEnableLogState(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_ENABLE_LOG, false) + + fun getExecuteInPostFsData(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_EXECUTE_IN_POST_FS_DATA, false) + + fun saveExecuteInPostFsData(context: Context, executeInPostFsData: Boolean) { + getPrefs(context).edit { putBoolean(KEY_EXECUTE_IN_POST_FS_DATA, executeInPostFsData) } + if (isAutoStartEnabled(context)) { + CoroutineScope(Dispatchers.Default).launch { + updateMagiskModule(context) + } + } + } + + // SUS挂载隐藏控制 + fun saveHideSusMountsForAllProcs(context: Context, hideForAll: Boolean) = + getPrefs(context).edit { putBoolean(KEY_HIDE_SUS_MOUNTS_FOR_ALL_PROCS, hideForAll) } + + fun getHideSusMountsForAllProcs(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_HIDE_SUS_MOUNTS_FOR_ALL_PROCS, true) + + // 隐藏BL锁脚本 + fun saveEnableHideBl(context: Context, enabled: Boolean) = + getPrefs(context).edit { putBoolean(KEY_ENABLE_HIDE_BL, enabled) } + + fun getEnableHideBl(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_ENABLE_HIDE_BL, true) + + + // 清理残留配置 + fun saveEnableCleanupResidue(context: Context, enabled: Boolean) = + getPrefs(context).edit { putBoolean(KEY_ENABLE_CLEANUP_RESIDUE, enabled) } + + fun getEnableCleanupResidue(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_ENABLE_CLEANUP_RESIDUE, false) + + // Zygote隔离服务卸载控制 + fun saveUmountForZygoteIsoService(context: Context, enabled: Boolean) = + getPrefs(context).edit { putBoolean(KEY_UMOUNT_FOR_ZYGOTE_ISO_SERVICE, enabled) } + + fun getUmountForZygoteIsoService(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_UMOUNT_FOR_ZYGOTE_ISO_SERVICE, false) + + // AVC日志欺骗配置 + fun saveEnableAvcLogSpoofing(context: Context, enabled: Boolean) = + getPrefs(context).edit { putBoolean(KEY_ENABLE_AVC_LOG_SPOOFING, enabled) } + + fun getEnableAvcLogSpoofing(context: Context): Boolean = + getPrefs(context).getBoolean(KEY_ENABLE_AVC_LOG_SPOOFING, false) + + + // 路径和配置管理 + fun saveSusPaths(context: Context, paths: Set) = + getPrefs(context).edit { putStringSet(KEY_SUS_PATHS, paths) } + + fun getSusPaths(context: Context): Set = + getPrefs(context).getStringSet(KEY_SUS_PATHS, emptySet()) ?: emptySet() + + // 循环路径管理 + fun saveSusLoopPaths(context: Context, paths: Set) = + getPrefs(context).edit { putStringSet(KEY_SUS_LOOP_PATHS, paths) } + + fun getSusLoopPaths(context: Context): Set = + getPrefs(context).getStringSet(KEY_SUS_LOOP_PATHS, emptySet()) ?: emptySet() + + fun saveSusMaps(context: Context, maps: Set) = + getPrefs(context).edit { putStringSet(KEY_SUS_MAPS, maps) } + + fun getSusMaps(context: Context): Set = + getPrefs(context).getStringSet(KEY_SUS_MAPS, emptySet()) ?: emptySet() + + fun saveSusMounts(context: Context, mounts: Set) = + getPrefs(context).edit { putStringSet(KEY_SUS_MOUNTS, mounts) } + + fun getSusMounts(context: Context): Set = + getPrefs(context).getStringSet(KEY_SUS_MOUNTS, emptySet()) ?: emptySet() + + fun saveTryUmounts(context: Context, umounts: Set) = + getPrefs(context).edit { putStringSet(KEY_TRY_UMOUNTS, umounts) } + + fun getTryUmounts(context: Context): Set = + getPrefs(context).getStringSet(KEY_TRY_UMOUNTS, emptySet()) ?: emptySet() + + fun saveKstatConfigs(context: Context, configs: Set) = + getPrefs(context).edit { putStringSet(KEY_KSTAT_CONFIGS, configs) } + + fun getKstatConfigs(context: Context): Set = + getPrefs(context).getStringSet(KEY_KSTAT_CONFIGS, emptySet()) ?: emptySet() + + fun saveAddKstatPaths(context: Context, paths: Set) = + getPrefs(context).edit { putStringSet(KEY_ADD_KSTAT_PATHS, paths) } + + fun getAddKstatPaths(context: Context): Set = + getPrefs(context).getStringSet(KEY_ADD_KSTAT_PATHS, emptySet()) ?: emptySet() + + @SuppressLint("SdCardPath") + fun saveAndroidDataPath(context: Context, path: String) = + getPrefs(context).edit { putString(KEY_ANDROID_DATA_PATH, path) } + + @SuppressLint("SdCardPath") + fun getAndroidDataPath(context: Context): String = + getPrefs(context).getString(KEY_ANDROID_DATA_PATH, "/sdcard/Android/data") ?: "/sdcard/Android/data" + + @SuppressLint("SdCardPath") + fun saveSdcardPath(context: Context, path: String) = + getPrefs(context).edit { putString(KEY_SDCARD_PATH, path) } + + @SuppressLint("SdCardPath") + fun getSdcardPath(context: Context): String = + getPrefs(context).getString(KEY_SDCARD_PATH, "/sdcard") ?: "/sdcard" + + // 获取已安装的应用列表 + @SuppressLint("QueryPermissionsNeeded") + suspend fun getInstalledApps(): List = withContext(Dispatchers.IO) { + try { + val allApps = mutableMapOf() + + // 从SuperUser中获取应用 + SuperUserViewModel.apps.forEach { superUserApp -> + try { + val isSystemApp = superUserApp.packageInfo.applicationInfo?.let { + (it.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + } ?: false + if (!isSystemApp) { + allApps[superUserApp.packageName] = AppInfo( + packageName = superUserApp.packageName, + appName = superUserApp.label, + packageInfo = superUserApp.packageInfo, + isSystemApp = false + ) + } + } catch (_: Exception) { + } + } + + // 检查每个应用的数据目录是否存在 + val filteredApps = allApps.values.map { appInfo -> + async(Dispatchers.IO) { + val dataPath = "$MEDIA_DATA_PATH/${appInfo.packageName}" + val exists = try { + val shell = getRootShell() + val outputList = mutableListOf() + val errorList = mutableListOf() + + val result = shell.newJob() + .add("[ -d \"$dataPath\" ] && echo 'exists' || echo 'not_exists'") + .to(outputList, errorList) + .exec() + + result.isSuccess && outputList.isNotEmpty() && outputList[0].trim() == "exists" + } catch (e: Exception) { + Log.w("SuSFSManager", "Failed to check directory for ${appInfo.packageName}: ${e.message}") + false + } + if (exists) appInfo else null + } + }.awaitAll().filterNotNull() + + filteredApps.sortedBy { it.appName } + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + // 获取应用的UID + private suspend fun getAppUid(context: Context, packageName: String): Int? = withContext(Dispatchers.IO) { + try { + // 从SuperUserViewModel中查找 + val superUserApp = SuperUserViewModel.apps.find { it.packageName == packageName } + if (superUserApp != null) { + return@withContext superUserApp.packageInfo.applicationInfo?.uid + } + + // 从PackageManager中查找 + val packageManager = context.packageManager + val packageInfo = packageManager.getPackageInfo(packageName, 0) + packageInfo.applicationInfo?.uid + } catch (e: Exception) { + Log.w("SuSFSManager", "Failed to get UID for package $packageName: ${e.message}") + null + } + } + + private fun buildUidPath(uid: Int): String = "$CGROUP_UID_PATH_PREFIX$uid" + + + // 快捷添加应用路径 + suspend fun addAppPaths(context: Context, packageName: String): Boolean { + val androidDataPath = getAndroidDataPath(context) + getSdcardPath(context) + + val path1 = "$androidDataPath/$packageName" + val path2 = "$MEDIA_DATA_PATH/$packageName" + + val uid = getAppUid(context, packageName) + if (uid == null) { + Log.w("SuSFSManager", "Failed to get UID for package: $packageName") + return false + } + + val path3 = buildUidPath(uid) + + var successCount = 0 + val totalCount = 3 + + // 添加第一个路径(Android/data路径) + if (addSusPath(context, path1)) { + successCount++ + } + + // 添加第二个路径(媒体数据路径) + if (addSusPath(context, path2)) { + successCount++ + } + + // 添加第三个路径(UID路径) + if (addSusPath(context, path3)) { + successCount++ + } + + val success = successCount > 0 + + Log.d("SuSFSManager", "Added $successCount/$totalCount paths for $packageName (UID: $uid)") + + return success + } + + // 获取所有配置的Map + private fun getAllConfigurations(context: Context): Map { + return mapOf( + KEY_UNAME_VALUE to getUnameValue(context), + KEY_BUILD_TIME_VALUE to getBuildTimeValue(context), + KEY_AUTO_START_ENABLED to isAutoStartEnabled(context), + KEY_SUS_PATHS to getSusPaths(context), + KEY_SUS_LOOP_PATHS to getSusLoopPaths(context), + KEY_SUS_MAPS to getSusMaps(context), + KEY_SUS_MOUNTS to getSusMounts(context), + KEY_TRY_UMOUNTS to getTryUmounts(context), + KEY_ANDROID_DATA_PATH to getAndroidDataPath(context), + KEY_SDCARD_PATH to getSdcardPath(context), + KEY_ENABLE_LOG to getEnableLogState(context), + KEY_EXECUTE_IN_POST_FS_DATA to getExecuteInPostFsData(context), + KEY_KSTAT_CONFIGS to getKstatConfigs(context), + KEY_ADD_KSTAT_PATHS to getAddKstatPaths(context), + KEY_HIDE_SUS_MOUNTS_FOR_ALL_PROCS to getHideSusMountsForAllProcs(context), + KEY_ENABLE_HIDE_BL to getEnableHideBl(context), + KEY_ENABLE_CLEANUP_RESIDUE to getEnableCleanupResidue(context), + KEY_UMOUNT_FOR_ZYGOTE_ISO_SERVICE to getUmountForZygoteIsoService(context), + KEY_ENABLE_AVC_LOG_SPOOFING to getEnableAvcLogSpoofing(context), + ) + } + + //生成备份文件名 + private fun generateBackupFileName(): String { + val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) + val timestamp = dateFormat.format(Date()) + return "SuSFS_Config_$timestamp$BACKUP_FILE_EXTENSION" + } + + // 获取设备信息 + private fun getDeviceInfo(): String { + return try { + "${Build.MANUFACTURER} ${Build.MODEL} (${Build.VERSION.RELEASE})" + } catch (_: Exception) { + "Unknown Device" + } + } + + // 创建配置备份 + suspend fun createBackup(context: Context, backupFilePath: String): Boolean = withContext(Dispatchers.IO) { + try { + val configurations = getAllConfigurations(context) + val backupData = BackupData( + version = getSuSFSVersion(), + timestamp = System.currentTimeMillis(), + deviceInfo = getDeviceInfo(), + configurations = configurations + ) + + val backupFile = File(backupFilePath) + backupFile.parentFile?.mkdirs() + + backupFile.writeText(backupData.toJson()) + + showToast(context, context.getString(R.string.susfs_backup_success, backupFile.name)) + true + } catch (e: Exception) { + e.printStackTrace() + showToast(context, context.getString(R.string.susfs_backup_failed, e.message ?: "Unknown error")) + false + } + } + + //从备份文件还原配置 + suspend fun restoreFromBackup(context: Context, backupFilePath: String): Boolean = withContext(Dispatchers.IO) { + try { + val backupFile = File(backupFilePath) + if (!backupFile.exists()) { + showToast(context, context.getString(R.string.susfs_backup_file_not_found)) + return@withContext false + } + + val backupContent = backupFile.readText() + val backupData = BackupData.fromJson(backupContent) + + if (backupData == null) { + showToast(context, context.getString(R.string.susfs_backup_invalid_format)) + return@withContext false + } + + // 检查备份版本兼容性 + if (backupData.version != getSuSFSVersion()) { + showToast(context, context.getString(R.string.susfs_backup_version_mismatch)) + } + + // 还原所有配置 + restoreConfigurations(context, backupData.configurations) + + // 如果自启动已启用,更新模块 + if (isAutoStartEnabled(context)) { + updateMagiskModule(context) + } + + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val backupDate = dateFormat.format(Date(backupData.timestamp)) + + showToast(context, context.getString(R.string.susfs_restore_success, backupDate, backupData.deviceInfo)) + true + } catch (e: Exception) { + e.printStackTrace() + showToast(context, context.getString(R.string.susfs_restore_failed, e.message ?: "Unknown error")) + false + } + } + + + // 还原配置到SharedPreferences + private fun restoreConfigurations(context: Context, configurations: Map) { + val prefs = getPrefs(context) + prefs.edit { + configurations.forEach { (key, value) -> + when (value) { + is String -> putString(key, value) + is Boolean -> putBoolean(key, value) + is Set<*> -> { + @Suppress("UNCHECKED_CAST") + putStringSet(key, value as Set) + } + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + } + } + } + } + + // 验证备份文件 + suspend fun validateBackupFile(backupFilePath: String): BackupData? = withContext(Dispatchers.IO) { + try { + val backupFile = File(backupFilePath) + if (!backupFile.exists()) { + return@withContext null + } + + val backupContent = backupFile.readText() + BackupData.fromJson(backupContent) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + // 获取备份文件路径 + fun getDefaultBackupFileName(): String { + return generateBackupFileName() + } + + // 槽位信息获取 + suspend fun getCurrentSlotInfo(): List = withContext(Dispatchers.IO) { + try { + val slotInfoList = mutableListOf() + val shell = Shell.getShell() + + listOf("boot_a", "boot_b").forEach { slot -> + val unameCmd = + "strings -n 20 /dev/block/by-name/$slot | awk '/Linux version/ && ++c==2 {print $3; exit}'" + val buildTimeCmd = "strings -n 20 /dev/block/by-name/$slot | sed -n '/Linux version.*#/{s/.*#/#/p;q}'" + + val uname = runCmd(shell, unameCmd).trim() + val buildTime = runCmd(shell, buildTimeCmd).trim() + + if (uname.isNotEmpty() && buildTime.isNotEmpty()) { + slotInfoList.add(SlotInfo(slot, uname.ifEmpty { "unknown" }, buildTime.ifEmpty { "unknown" })) + } + } + + slotInfoList + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + suspend fun getCurrentActiveSlot(): String = withContext(Dispatchers.IO) { + try { + val shell = Shell.getShell() + val suffix = runCmd(shell, "getprop ro.boot.slot_suffix").trim() + when (suffix) { + "_a" -> "boot_a" + "_b" -> "boot_b" + else -> "unknown" + } + } catch (_: Exception) { + "unknown" + } + } + + // 二进制文件管理 + private suspend fun copyBinaryFromAssets(context: Context): String? = withContext(Dispatchers.IO) { + try { + val binaryName = getSuSFSBinaryName(context) + val targetPath = getSuSFSTargetPath() + val tempFile = File(context.cacheDir, binaryName) + + context.assets.open(binaryName).use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + + val success = runCmdWithResult("cp '${tempFile.absolutePath}' '$targetPath' && chmod 755 '$targetPath'").isSuccess + tempFile.delete() + + if (success && runCmdWithResult("test -f '$targetPath'").isSuccess) targetPath else null + } catch (e: IOException) { + e.printStackTrace() + null + } + } + + fun isBinaryAvailable(context: Context): Boolean = try { + context.assets.open(getSuSFSBinaryName(context)).use { true } + } catch (_: IOException) { false } + + // 命令执行 + private suspend fun executeSusfsCommand(context: Context, command: String): Boolean = withContext(Dispatchers.IO) { + try { + val binaryPath = copyBinaryFromAssets(context) ?: run { + showToast(context, context.getString(R.string.susfs_binary_not_found)) + return@withContext false + } + + val result = runCmdWithResult("$binaryPath $command") + + if (!result.isSuccess) { + showToast(context, "${context.getString(R.string.susfs_command_failed)}\n${result.output}\n${result.errorOutput}") + } + + result.isSuccess + } catch (e: Exception) { + e.printStackTrace() + showToast(context, context.getString(R.string.susfs_command_error, e.message ?: "Unknown error")) + false + } + } + + private suspend fun executeSusfsCommandWithOutput(context: Context, command: String): CommandResult = withContext(Dispatchers.IO) { + try { + val binaryPath = copyBinaryFromAssets(context) ?: return@withContext CommandResult( + false, "", context.getString(R.string.susfs_binary_not_found) + ) + runCmdWithResult("$binaryPath $command") + } catch (e: Exception) { + e.printStackTrace() + CommandResult(false, "", e.message ?: "Unknown error") + } + } + + private suspend fun showToast(context: Context, message: String) = withContext(Dispatchers.Main) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + + /** + * 模块管理 + */ + private suspend fun updateMagiskModule(context: Context): Boolean { + return removeMagiskModule() && createMagiskModule(context) + } + + /** + * 模块创建方法 + */ + private suspend fun createMagiskModule(context: Context): Boolean = withContext(Dispatchers.IO) { + try { + val config = getCurrentModuleConfig(context) + + // 创建模块目录 + if (!runCmdWithResult("mkdir -p $MODULE_PATH").isSuccess) return@withContext false + + // 创建module.prop + val moduleProp = ScriptGenerator.generateModuleProp(MODULE_ID) + if (!runCmdWithResult("cat > $MODULE_PATH/module.prop << 'EOF'\n$moduleProp\nEOF").isSuccess) return@withContext false + + // 生成并创建所有脚本文件 + val scripts = ScriptGenerator.generateAllScripts(config) + + scripts.all { (filename, content) -> + runCmdWithResult("cat > $MODULE_PATH/$filename << 'EOF'\n$content\nEOF").isSuccess && + runCmdWithResult("chmod 755 $MODULE_PATH/$filename").isSuccess + } + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + private suspend fun removeMagiskModule(): Boolean = withContext(Dispatchers.IO) { + try { + runCmdWithResult("rm -rf $MODULE_PATH").isSuccess + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + // 功能状态获取 + suspend fun getEnabledFeatures(context: Context): List = withContext(Dispatchers.IO) { + try { + val featuresOutput = getSuSFSFeatures() + + if (featuresOutput.isNotBlank() && featuresOutput != "Invalid") { + parseEnabledFeaturesFromOutput(context, featuresOutput) + } else { + getDefaultDisabledFeatures(context) + } + } catch (e: Exception) { + e.printStackTrace() + getDefaultDisabledFeatures(context) + } + } + + private fun parseEnabledFeaturesFromOutput(context: Context, featuresOutput: String): List { + val enabledConfigs = featuresOutput.lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + + val featureMap = mapOf( + "CONFIG_KSU_SUSFS_SUS_PATH" to context.getString(R.string.sus_path_feature_label), + "CONFIG_KSU_SUSFS_SUS_MOUNT" to context.getString(R.string.sus_mount_feature_label), + "CONFIG_KSU_SUSFS_TRY_UMOUNT" to context.getString(R.string.try_umount_feature_label), + "CONFIG_KSU_SUSFS_SPOOF_UNAME" to context.getString(R.string.spoof_uname_feature_label), + "CONFIG_KSU_SUSFS_SPOOF_CMDLINE_OR_BOOTCONFIG" to context.getString(R.string.spoof_cmdline_feature_label), + "CONFIG_KSU_SUSFS_OPEN_REDIRECT" to context.getString(R.string.open_redirect_feature_label), + "CONFIG_KSU_SUSFS_ENABLE_LOG" to context.getString(R.string.enable_log_feature_label), + "CONFIG_KSU_SUSFS_AUTO_ADD_TRY_UMOUNT_FOR_BIND_MOUNT" to context.getString(R.string.auto_try_umount_bind_feature_label), + "CONFIG_KSU_SUSFS_HIDE_KSU_SUSFS_SYMBOLS" to context.getString(R.string.hide_symbols_feature_label), + "CONFIG_KSU_SUSFS_SUS_KSTAT" to context.getString(R.string.sus_kstat_feature_label), + ) + + + return featureMap.map { (configKey, displayName) -> + val isEnabled = enabledConfigs.contains(configKey) + + val statusText = if (isEnabled) { + context.getString(R.string.susfs_feature_enabled) + } else { + context.getString(R.string.susfs_feature_disabled) + } + + val canConfigure = displayName == context.getString(R.string.enable_log_feature_label) + + EnabledFeature(displayName, isEnabled, statusText, canConfigure) + }.sortedBy { it.name } + } + + private fun getDefaultDisabledFeatures(context: Context): List { + val defaultFeatures = listOf( + "sus_path_feature_label" to context.getString(R.string.sus_path_feature_label), + "sus_mount_feature_label" to context.getString(R.string.sus_mount_feature_label), + "try_umount_feature_label" to context.getString(R.string.try_umount_feature_label), + "spoof_uname_feature_label" to context.getString(R.string.spoof_uname_feature_label), + "spoof_cmdline_feature_label" to context.getString(R.string.spoof_cmdline_feature_label), + "open_redirect_feature_label" to context.getString(R.string.open_redirect_feature_label), + "enable_log_feature_label" to context.getString(R.string.enable_log_feature_label), + "auto_try_umount_bind_feature_label" to context.getString(R.string.auto_try_umount_bind_feature_label), + "hide_symbols_feature_label" to context.getString(R.string.hide_symbols_feature_label), + "sus_kstat_feature_label" to context.getString(R.string.sus_kstat_feature_label), + ) + + return defaultFeatures.map { (_, displayName) -> + EnabledFeature( + name = displayName, + isEnabled = false, + statusText = context.getString(R.string.susfs_feature_disabled), + canConfigure = displayName == context.getString(R.string.enable_log_feature_label) + ) + }.sortedBy { it.name } + } + + // sus日志开关 + suspend fun setEnableLog(context: Context, enabled: Boolean): Boolean { + val success = executeSusfsCommand(context, "enable_log ${if (enabled) 1 else 0}") + if (success) { + saveEnableLogState(context, enabled) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, if (enabled) context.getString(R.string.susfs_log_enabled) else context.getString(R.string.susfs_log_disabled)) + } + return success + } + + // AVC日志欺骗开关 + suspend fun setEnableAvcLogSpoofing(context: Context, enabled: Boolean): Boolean { + if (!isSusVersion159()) { + return false + } + + val success = executeSusfsCommand(context, "enable_avc_log_spoofing ${if (enabled) 1 else 0}") + if (success) { + saveEnableAvcLogSpoofing(context, enabled) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, if (enabled) + context.getString(R.string.avc_log_spoofing_enabled) + else + context.getString(R.string.avc_log_spoofing_disabled) + ) + } + return success + } + + // SUS挂载隐藏控制 + suspend fun setHideSusMountsForAllProcs(context: Context, hideForAll: Boolean): Boolean { + if (!isSusVersion158()) { + return false + } + + val success = executeSusfsCommand(context, "hide_sus_mnts_for_all_procs ${if (hideForAll) 1 else 0}") + if (success) { + saveHideSusMountsForAllProcs(context, hideForAll) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, if (hideForAll) + context.getString(R.string.susfs_hide_mounts_all_enabled) + else + context.getString(R.string.susfs_hide_mounts_all_disabled) + ) + } + return success + } + + // uname和构建时间 + @SuppressLint("StringFormatMatches") + suspend fun setUname(context: Context, unameValue: String, buildTimeValue: String): Boolean { + val success = executeSusfsCommand(context, "set_uname '$unameValue' '$buildTimeValue'") + if (success) { + saveUnameValue(context, unameValue) + saveBuildTimeValue(context, buildTimeValue) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.susfs_uname_set_success, unameValue, buildTimeValue)) + } + return success + } + + // 添加SUS路径 + @SuppressLint("StringFormatInvalid") + suspend fun addSusPath(context: Context, path: String): Boolean { + // 如果是1.5.8版本,先设置路径配置 + if (isSusVersion158()) { + // 获取当前配置的路径,如果没有配置则使用默认值 + val androidDataPath = getAndroidDataPath(context) + val sdcardPath = getSdcardPath(context) + + // 先设置Android Data路径 + val androidDataSuccess = executeSusfsCommand(context, "set_android_data_root_path '$androidDataPath'") + if (androidDataSuccess) { + showToast(context, context.getString(R.string.susfs_android_data_path_set, androidDataPath)) + } + + // 再设置SD卡路径 + val sdcardSuccess = executeSusfsCommand(context, "set_sdcard_root_path '$sdcardPath'") + if (sdcardSuccess) { + showToast(context, context.getString(R.string.susfs_sdcard_path_set, sdcardPath)) + } + + // 如果路径设置失败,记录但不阻止继续执行 + if (!androidDataSuccess || !sdcardSuccess) { + showToast(context, context.getString(R.string.susfs_path_setup_warning)) + } + } + + // 执行添加SUS路径命令 + val result = executeSusfsCommandWithOutput(context, "add_sus_path '$path'") + val isActuallySuccessful = result.isSuccess && !result.output.contains("not found, skip adding") + + if (isActuallySuccessful) { + saveSusPaths(context, getSusPaths(context) + path) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.susfs_sus_path_added_success, path)) + } else { + val errorMessage = if (result.output.contains("not found, skip adding")) { + context.getString(R.string.susfs_path_not_found_error, path) + } else { + "${context.getString(R.string.susfs_command_failed)}\n${result.output}\n${result.errorOutput}" + } + showToast(context, errorMessage) + } + return isActuallySuccessful + } + + suspend fun removeSusPath(context: Context, path: String): Boolean { + saveSusPaths(context, getSusPaths(context) - path) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "SUS path removed: $path") + return true + } + + // 编辑SUS路径 + suspend fun editSusPath(context: Context, oldPath: String, newPath: String): Boolean { + return try { + val currentPaths = getSusPaths(context).toMutableSet() + if (!currentPaths.remove(oldPath)) { + showToast(context, "Original path not found: $oldPath") + return false + } + + saveSusPaths(context, currentPaths) + + val success = addSusPath(context, newPath) + + if (success) { + showToast(context, "SUS path updated: $oldPath -> $newPath") + return true + } else { + // 如果添加新路径失败,恢复旧路径 + currentPaths.add(oldPath) + saveSusPaths(context, currentPaths) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update path, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating SUS path: ${e.message}") + false + } + } + + // 循环路径相关方法 + @SuppressLint("SdCardPath") + private fun isValidLoopPath(path: String): Boolean { + return !path.startsWith("/storage/") && !path.startsWith("/sdcard/") + } + + @SuppressLint("StringFormatInvalid") + suspend fun addSusLoopPath(context: Context, path: String): Boolean { + // 检查路径是否有效 + if (!isValidLoopPath(path)) { + showToast(context, context.getString(R.string.susfs_loop_path_invalid_location)) + return false + } + + // 执行添加循环路径命令 + val result = executeSusfsCommandWithOutput(context, "add_sus_path_loop '$path'") + val isActuallySuccessful = result.isSuccess && !result.output.contains("not found, skip adding") + + if (isActuallySuccessful) { + saveSusLoopPaths(context, getSusLoopPaths(context) + path) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.susfs_loop_path_added_success, path)) + } else { + val errorMessage = if (result.output.contains("not found, skip adding")) { + context.getString(R.string.susfs_path_not_found_error, path) + } else { + "${context.getString(R.string.susfs_command_failed)}\n${result.output}\n${result.errorOutput}" + } + showToast(context, errorMessage) + } + return isActuallySuccessful + } + + suspend fun removeSusLoopPath(context: Context, path: String): Boolean { + saveSusLoopPaths(context, getSusLoopPaths(context) - path) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.susfs_loop_path_removed, path)) + return true + } + + // 编辑循环路径 + suspend fun editSusLoopPath(context: Context, oldPath: String, newPath: String): Boolean { + // 检查新路径是否有效 + if (!isValidLoopPath(newPath)) { + showToast(context, context.getString(R.string.susfs_loop_path_invalid_location)) + return false + } + + return try { + val currentPaths = getSusLoopPaths(context).toMutableSet() + if (!currentPaths.remove(oldPath)) { + showToast(context, "Original loop path not found: $oldPath") + return false + } + + saveSusLoopPaths(context, currentPaths) + + val success = addSusLoopPath(context, newPath) + + if (success) { + showToast(context, context.getString(R.string.susfs_loop_path_updated, oldPath, newPath)) + return true + } else { + // 如果添加新路径失败,恢复旧路径 + currentPaths.add(oldPath) + saveSusLoopPaths(context, currentPaths) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update loop path, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating SUS loop path: ${e.message}") + false + } + } + + // 添加 SUS Maps + suspend fun addSusMap(context: Context, map: String): Boolean { + val success = executeSusfsCommand(context, "add_sus_map '$map'") + if (success) { + saveSusMaps(context, getSusMaps(context) + map) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.susfs_sus_map_added_success, map)) + } + return success + } + + suspend fun removeSusMap(context: Context, map: String): Boolean { + saveSusMaps(context, getSusMaps(context) - map) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.susfs_sus_map_removed, map)) + return true + } + + suspend fun editSusMap(context: Context, oldMap: String, newMap: String): Boolean { + return try { + val currentMaps = getSusMaps(context).toMutableSet() + if (!currentMaps.remove(oldMap)) { + showToast(context, "Original SUS map not found: $oldMap") + return false + } + + saveSusMaps(context, currentMaps) + + val success = addSusMap(context, newMap) + + if (success) { + showToast(context, context.getString(R.string.susfs_sus_map_updated, oldMap, newMap)) + return true + } else { + // 如果添加新映射失败,恢复旧映射 + currentMaps.add(oldMap) + saveSusMaps(context, currentMaps) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update SUS map, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating SUS map: ${e.message}") + false + } + } + + // 添加SUS挂载 + suspend fun addSusMount(context: Context, mount: String): Boolean { + val success = executeSusfsCommand(context, "add_sus_mount '$mount'") + if (success) { + saveSusMounts(context, getSusMounts(context) + mount) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + } + return success + } + + suspend fun removeSusMount(context: Context, mount: String): Boolean { + saveSusMounts(context, getSusMounts(context) - mount) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Removed SUS mount: $mount") + return true + } + + // 编辑SUS挂载 + suspend fun editSusMount(context: Context, oldMount: String, newMount: String): Boolean { + return try { + val currentMounts = getSusMounts(context).toMutableSet() + if (!currentMounts.remove(oldMount)) { + showToast(context, "Original mount not found: $oldMount") + return false + } + + saveSusMounts(context, currentMounts) + + val success = addSusMount(context, newMount) + + if (success) { + showToast(context, "SUS mount updated: $oldMount -> $newMount") + return true + } else { + // 如果添加新挂载点失败,恢复旧挂载点 + currentMounts.add(oldMount) + saveSusMounts(context, currentMounts) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update mount, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating SUS mount: ${e.message}") + false + } + } + + // 添加尝试卸载 + suspend fun addTryUmount(context: Context, path: String, mode: Int): Boolean { + val commandSuccess = executeSusfsCommand(context, "add_try_umount '$path' $mode") + saveTryUmounts(context, getTryUmounts(context) + "$path|$mode") + if (isAutoStartEnabled(context)) updateMagiskModule(context) + + showToast(context, if (commandSuccess) { + context.getString(R.string.susfs_try_umount_added_success, path) + } else { + context.getString(R.string.susfs_try_umount_added_saved, path) + }) + return true + } + + suspend fun removeTryUmount(context: Context, umountEntry: String): Boolean { + saveTryUmounts(context, getTryUmounts(context) - umountEntry) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + val path = umountEntry.split("|").firstOrNull() ?: umountEntry + showToast(context, "Removed Try to uninstall: $path") + return true + } + + // 编辑尝试卸载 + suspend fun editTryUmount(context: Context, oldEntry: String, newPath: String, newMode: Int): Boolean { + return try { + val currentUmounts = getTryUmounts(context).toMutableSet() + if (!currentUmounts.remove(oldEntry)) { + showToast(context, "Original umount entry not found: $oldEntry") + return false + } + + saveTryUmounts(context, currentUmounts) + + val success = addTryUmount(context, newPath, newMode) + + if (success) { + showToast(context, "Try umount updated: $oldEntry -> $newPath|$newMode") + return true + } else { + // 如果添加新条目失败,恢复旧条目 + currentUmounts.add(oldEntry) + saveTryUmounts(context, currentUmounts) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update umount entry, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating try umount: ${e.message}") + false + } + } + + // Zygote隔离服务卸载控制 + suspend fun setUmountForZygoteIsoService(context: Context, enabled: Boolean): Boolean { + if (!isSusVersion158()) { + return false + } + + val result = executeSusfsCommandWithOutput(context, "umount_for_zygote_iso_service ${if (enabled) 1 else 0}") + val success = result.isSuccess && result.output.isEmpty() + + if (success) { + saveUmountForZygoteIsoService(context, enabled) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, if (enabled) + context.getString(R.string.umount_zygote_iso_service_enabled) + else + context.getString(R.string.umount_zygote_iso_service_disabled) + ) + } else { + showToast(context, context.getString(R.string.susfs_command_failed)) + } + return success + } + + // 添加kstat配置 + suspend fun addKstatStatically(context: Context, path: String, ino: String, dev: String, nlink: String, + size: String, atime: String, atimeNsec: String, mtime: String, mtimeNsec: String, + ctime: String, ctimeNsec: String, blocks: String, blksize: String): Boolean { + val command = "add_sus_kstat_statically '$path' '$ino' '$dev' '$nlink' '$size' '$atime' '$atimeNsec' '$mtime' '$mtimeNsec' '$ctime' '$ctimeNsec' '$blocks' '$blksize'" + val success = executeSusfsCommand(context, command) + if (success) { + val configEntry = "$path|$ino|$dev|$nlink|$size|$atime|$atimeNsec|$mtime|$mtimeNsec|$ctime|$ctimeNsec|$blocks|$blksize" + saveKstatConfigs(context, getKstatConfigs(context) + configEntry) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.kstat_static_config_added, path)) + } + return success + } + + suspend fun removeKstatConfig(context: Context, config: String): Boolean { + saveKstatConfigs(context, getKstatConfigs(context) - config) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + val path = config.split("|").firstOrNull() ?: config + showToast(context, context.getString(R.string.kstat_config_removed, path)) + return true + } + + // 编辑kstat配置 + @SuppressLint("StringFormatInvalid") + suspend fun editKstatConfig(context: Context, oldConfig: String, path: String, ino: String, dev: String, nlink: String, + size: String, atime: String, atimeNsec: String, mtime: String, mtimeNsec: String, + ctime: String, ctimeNsec: String, blocks: String, blksize: String): Boolean { + return try { + val currentConfigs = getKstatConfigs(context).toMutableSet() + if (!currentConfigs.remove(oldConfig)) { + showToast(context, "Original kstat config not found") + return false + } + + saveKstatConfigs(context, currentConfigs) + + val success = addKstatStatically(context, path, ino, dev, nlink, size, atime, atimeNsec, + mtime, mtimeNsec, ctime, ctimeNsec, blocks, blksize) + + if (success) { + showToast(context, context.getString(R.string.kstat_config_updated, path)) + return true + } else { + // 如果添加新配置失败,恢复旧配置 + currentConfigs.add(oldConfig) + saveKstatConfigs(context, currentConfigs) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update kstat config, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating kstat config: ${e.message}") + false + } + } + + // 添加kstat路径 + suspend fun addKstat(context: Context, path: String): Boolean { + val success = executeSusfsCommand(context, "add_sus_kstat '$path'") + if (success) { + saveAddKstatPaths(context, getAddKstatPaths(context) + path) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.kstat_path_added, path)) + } + return success + } + + suspend fun removeAddKstat(context: Context, path: String): Boolean { + saveAddKstatPaths(context, getAddKstatPaths(context) - path) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, context.getString(R.string.kstat_path_removed, path)) + return true + } + + // 编辑kstat路径 + @SuppressLint("StringFormatInvalid") + suspend fun editAddKstat(context: Context, oldPath: String, newPath: String): Boolean { + return try { + val currentPaths = getAddKstatPaths(context).toMutableSet() + if (!currentPaths.remove(oldPath)) { + showToast(context, "Original kstat path not found: $oldPath") + return false + } + + saveAddKstatPaths(context, currentPaths) + + val success = addKstat(context, newPath) + + if (success) { + showToast(context, context.getString(R.string.kstat_path_updated, oldPath, newPath)) + return true + } else { + // 如果添加新路径失败,恢复旧路径 + currentPaths.add(oldPath) + saveAddKstatPaths(context, currentPaths) + if (isAutoStartEnabled(context)) updateMagiskModule(context) + showToast(context, "Failed to update kstat path, reverted to original") + return false + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, "Error updating kstat path: ${e.message}") + false + } + } + + // 更新kstat + suspend fun updateKstat(context: Context, path: String): Boolean { + val success = executeSusfsCommand(context, "update_sus_kstat '$path'") + if (success) showToast(context, context.getString(R.string.kstat_updated, path)) + return success + } + + // 更新kstat全克隆 + suspend fun updateKstatFullClone(context: Context, path: String): Boolean { + val success = executeSusfsCommand(context, "update_sus_kstat_full_clone '$path'") + if (success) showToast(context, context.getString(R.string.kstat_full_clone_updated, path)) + return success + } + + // 设置Android数据路径 + suspend fun setAndroidDataPath(context: Context, path: String): Boolean { + val success = executeSusfsCommand(context, "set_android_data_root_path '$path'") + if (success) { + saveAndroidDataPath(context, path) + if (isAutoStartEnabled(context)) { + CoroutineScope(Dispatchers.Default).launch { + updateMagiskModule(context) + } + } + } + return success + } + + // 设置SD卡路径 + suspend fun setSdcardPath(context: Context, path: String): Boolean { + val success = executeSusfsCommand(context, "set_sdcard_root_path '$path'") + if (success) { + saveSdcardPath(context, path) + if (isAutoStartEnabled(context)) { + CoroutineScope(Dispatchers.Default).launch { + updateMagiskModule(context) + } + } + } + return success + } + + /** + * 自启动配置检查 + */ + fun hasConfigurationForAutoStart(context: Context): Boolean { + val config = getCurrentModuleConfig(context) + return config.hasAutoStartConfig() || runBlocking { + getEnabledFeatures(context).any { it.isEnabled } + } + } + + /** + * 自启动配置方法 + */ + suspend fun configureAutoStart(context: Context, enabled: Boolean): Boolean = withContext(Dispatchers.IO) { + try { + if (enabled) { + if (!hasConfigurationForAutoStart(context)) { + showToast(context, context.getString(R.string.susfs_no_config_to_autostart)) + return@withContext false + } + + val targetPath = getSuSFSTargetPath() + if (!runCmdWithResult("test -f '$targetPath'").isSuccess) { + copyBinaryFromAssets(context) ?: run { + showToast(context, context.getString(R.string.susfs_binary_not_found)) + return@withContext false + } + } + + val success = createMagiskModule(context) + if (success) { + setAutoStartEnabled(context, true) + showToast(context, context.getString(R.string.susfs_autostart_enabled_success, MODULE_PATH)) + } else { + showToast(context, context.getString(R.string.susfs_autostart_enable_failed)) + } + success + } else { + val success = removeMagiskModule() + if (success) { + setAutoStartEnabled(context, false) + showToast(context, context.getString(R.string.susfs_autostart_disabled_success)) + } else { + showToast(context, context.getString(R.string.susfs_autostart_disable_failed)) + } + success + } + } catch (e: Exception) { + e.printStackTrace() + showToast(context, context.getString(R.string.susfs_autostart_error, e.message ?: "Unknown error")) + false + } + } + + suspend fun resetToDefault(context: Context): Boolean { + val success = setUname(context, DEFAULT_UNAME, DEFAULT_BUILD_TIME) + if (success && isAutoStartEnabled(context)) { + configureAutoStart(context, false) + } + return success + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/util/SuSFSModuleScripts.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/util/SuSFSModuleScripts.kt new file mode 100644 index 0000000..e0d9bae --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/susfs/util/SuSFSModuleScripts.kt @@ -0,0 +1,555 @@ +package com.sukisu.ultra.ui.susfs.util + +import android.annotation.SuppressLint + +/** + * Magisk模块脚本生成器 + * 用于生成各种启动脚本的内容 + */ +object ScriptGenerator { + + // 常量定义 + private const val DEFAULT_UNAME = "default" + private const val DEFAULT_BUILD_TIME = "default" + private const val LOG_DIR = "/data/adb/ksu/log" + + /** + * 生成所有脚本文件 + */ + fun generateAllScripts(config: SuSFSManager.ModuleConfig): Map { + return mapOf( + "service.sh" to generateServiceScript(config), + "post-fs-data.sh" to generatePostFsDataScript(config), + "post-mount.sh" to generatePostMountScript(config), + "boot-completed.sh" to generateBootCompletedScript(config) + ) + } + + // 日志相关的通用脚本片段 + private fun generateLogSetup(logFileName: String): String = """ + # 日志目录 + LOG_DIR="$LOG_DIR" + LOG_FILE="${'$'}LOG_DIR/$logFileName" + + # 创建日志目录 + mkdir -p "${'$'}LOG_DIR" + + # 获取当前时间 + get_current_time() { + date '+%Y-%m-%d %H:%M:%S' + } + """.trimIndent() + + // 二进制文件检查的通用脚本片段 + private fun generateBinaryCheck(targetPath: String): String = """ + # 检查SuSFS二进制文件 + SUSFS_BIN="$targetPath" + if [ ! -f "${'$'}SUSFS_BIN" ]; then + echo "$(get_current_time): SuSFS二进制文件未找到: ${'$'}SUSFS_BIN" >> "${'$'}LOG_FILE" + exit 1 + fi + """.trimIndent() + + /** + * 生成service.sh脚本内容 + */ + @SuppressLint("SdCardPath") + private fun generateServiceScript(config: SuSFSManager.ModuleConfig): String { + return buildString { + appendLine("#!/system/bin/sh") + appendLine("# SuSFS Service Script") + appendLine("# 在系统服务启动后执行") + appendLine() + appendLine(generateLogSetup("susfs_service.log")) + appendLine() + appendLine(generateBinaryCheck(config.targetPath)) + appendLine() + + if (shouldConfigureInService(config)) { + // 添加SUS路径 (仅在不支持隐藏挂载时) + if (!config.support158 && config.susPaths.isNotEmpty()) { + appendLine() + appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done") + appendLine("sleep 45") + generateSusPathsSection(config.susPaths) + } + + // 设置uname和构建时间 + generateUnameSection(config) + + // 添加Kstat配置 + generateKstatSection(config.kstatConfigs, config.addKstatPaths) + } + + // 添加日志设置 + generateLogSettingSection(config.enableLog) + + // 隐藏BL相关配置 + if (config.enableHideBl) { + generateHideBlSection() + } + + // 清理工具残留 + if (config.enableCleanupResidue) { + generateCleanupResidueSection() + } + + appendLine("echo \"$(get_current_time): Service脚本执行完成\" >> \"${'$'}LOG_FILE\"") + } + } + + /** + * 判断是否需要在service中配置 + */ + private fun shouldConfigureInService(config: SuSFSManager.ModuleConfig): Boolean { + return config.susPaths.isNotEmpty() || + config.susLoopPaths.isNotEmpty() || + config.kstatConfigs.isNotEmpty() || + config.addKstatPaths.isNotEmpty() || + (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) + } + + private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) { + appendLine("# 设置日志启用状态") + val logValue = if (enableLog) 1 else 0 + appendLine("\"${'$'}SUSFS_BIN\" enable_log $logValue") + appendLine("echo \"$(get_current_time): 日志功能设置为: ${if (enableLog) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + + private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) { + appendLine("# 设置AVC日志欺骗状态") + val avcLogValue = if (enableAvcLogSpoofing) 1 else 0 + appendLine("\"${'$'}SUSFS_BIN\" enable_avc_log_spoofing $avcLogValue") + appendLine("echo \"$(get_current_time): AVC日志欺骗功能设置为: ${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + + private fun StringBuilder.generateSusPathsSection(susPaths: Set) { + if (susPaths.isNotEmpty()) { + appendLine("# 添加SUS路径") + susPaths.forEach { path -> + appendLine("\"${'$'}SUSFS_BIN\" add_sus_path '$path'") + appendLine("echo \"$(get_current_time): 添加SUS路径: $path\" >> \"${'$'}LOG_FILE\"") + } + appendLine() + } + } + + private fun StringBuilder.generateSusLoopPathsSection(susLoopPaths: Set) { + if (susLoopPaths.isNotEmpty()) { + appendLine("# 添加SUS循环路径") + susLoopPaths.forEach { path -> + appendLine("\"${'$'}SUSFS_BIN\" add_sus_path_loop '$path'") + appendLine("echo \"$(get_current_time): 添加SUS循环路径: $path\" >> \"${'$'}LOG_FILE\"") + } + appendLine() + } + } + + @SuppressLint("SdCardPath") + private fun StringBuilder.generateKstatSection( + kstatConfigs: Set, + addKstatPaths: Set + ) { + // 添加Kstat路径 + if (addKstatPaths.isNotEmpty()) { + appendLine("# 添加Kstat路径") + addKstatPaths.forEach { path -> + appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat '$path'") + appendLine("echo \"$(get_current_time): 添加Kstat路径: $path\" >> \"${'$'}LOG_FILE\"") + } + appendLine() + } + + // 添加Kstat静态配置 + if (kstatConfigs.isNotEmpty()) { + appendLine("# 添加Kstat静态配置") + kstatConfigs.forEach { config -> + val parts = config.split("|") + if (parts.size >= 13) { + val path = parts[0] + val params = parts.drop(1).joinToString("' '", "'", "'") + appendLine() + appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat_statically '$path' $params") + appendLine("echo \"$(get_current_time): 添加Kstat静态配置: $path\" >> \"${'$'}LOG_FILE\"") + appendLine() + appendLine("\"${'$'}SUSFS_BIN\" update_sus_kstat '$path'") + appendLine("echo \"$(get_current_time): 更新Kstat配置: $path\" >> \"${'$'}LOG_FILE\"") + } + } + appendLine() + } + } + + private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) { + if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) { + appendLine("# 设置uname和构建时间") + appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'") + appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + } + + private fun StringBuilder.generateHideBlSection() { + appendLine("# 隐藏BL 来自 Shamiko 脚本") + appendLine( + """ + RESETPROP_BIN="/data/adb/ksu/bin/resetprop" + + check_reset_prop() { + local NAME=$1 + local EXPECTED=$2 + local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME) + [ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED + } + + check_missing_prop() { + local NAME=$1 + local EXPECTED=$2 + local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME) + [ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED + } + + check_missing_match_prop() { + local NAME=$1 + local EXPECTED=$2 + local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME) + [ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED + [ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED + } + + contains_reset_prop() { + local NAME=$1 + local CONTAINS=$2 + local NEWVAL=$3 + case "$("${'$'}RESETPROP_BIN" ${'$'}NAME)" in + *"${'$'}CONTAINS"*) "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}NEWVAL ;; + esac + } + """.trimIndent()) + appendLine() + appendLine("sleep 30") + appendLine() + appendLine("\"${'$'}RESETPROP_BIN\" -w sys.boot_completed 0") + + // 添加所有系统属性重置 + val systemProps = listOf( + "ro.boot.vbmeta.invalidate_on_error" to "yes", + "ro.boot.vbmeta.avb_version" to "1.2", + "ro.boot.vbmeta.hash_alg" to "sha256", + "ro.boot.vbmeta.size" to "19968", + "ro.boot.vbmeta.device_state" to "locked", + "ro.boot.verifiedbootstate" to "green", + "ro.boot.flash.locked" to "1", + "ro.boot.veritymode" to "enforcing", + "ro.boot.warranty_bit" to "0", + "ro.warranty_bit" to "0", + "ro.debuggable" to "0", + "ro.force.debuggable" to "0", + "ro.secure" to "1", + "ro.adb.secure" to "1", + "ro.build.type" to "user", + "ro.build.tags" to "release-keys", + "ro.vendor.boot.warranty_bit" to "0", + "ro.vendor.warranty_bit" to "0", + "vendor.boot.vbmeta.device_state" to "locked", + "vendor.boot.verifiedbootstate" to "green", + "sys.oem_unlock_allowed" to "0", + "ro.secureboot.lockstate" to "locked", + "ro.boot.realmebootstate" to "green", + "ro.boot.realme.lockstate" to "1", + "ro.crypto.state" to "encrypted" + ) + + systemProps.forEach { (prop, value) -> + when { + prop.startsWith("ro.boot.vbmeta") && prop.endsWith("_on_error") -> + appendLine("check_missing_prop \"$prop\" \"$value\"") + prop.contains("device_state") || prop.contains("verifiedbootstate") -> + appendLine("check_missing_match_prop \"$prop\" \"$value\"") + else -> + appendLine("check_reset_prop \"$prop\" \"$value\"") + } + } + + appendLine() + appendLine("# Hide adb debugging traces") + appendLine("resetprop \"sys.usb.adb.disabled\" \" \"") + appendLine() + + appendLine("# Hide recovery boot mode") + appendLine("contains_reset_prop \"ro.bootmode\" \"recovery\" \"unknown\"") + appendLine("contains_reset_prop \"ro.boot.bootmode\" \"recovery\" \"unknown\"") + appendLine("contains_reset_prop \"vendor.boot.bootmode\" \"recovery\" \"unknown\"") + appendLine() + + appendLine("# Hide cloudphone detection") + appendLine("[ -n \"$(resetprop ro.kernel.qemu)\" ] && resetprop ro.kernel.qemu \"\"") + appendLine() + } + + // 清理残留脚本生成 + private fun StringBuilder.generateCleanupResidueSection() { + appendLine("# 清理工具残留文件") + appendLine("echo \"$(get_current_time): 开始清理工具残留\" >> \"${'$'}LOG_FILE\"") + appendLine() + + // 定义清理函数 + appendLine(""" + cleanup_path() { + local path="$1" + local desc="$2" + local current="$3" + local total="$4" + + if [ -n "${'$'}desc" ]; then + echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path (${'$'}desc)" >> "${'$'}LOG_FILE" + else + echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path" >> "${'$'}LOG_FILE" + fi + + if rm -rf "${'$'}path" 2>/dev/null; then + echo "$(get_current_time): ✓ 成功清理: ${'$'}path" >> "${'$'}LOG_FILE" + else + echo "$(get_current_time): ✗ 清理失败或不存在: ${'$'}path" >> "${'$'}LOG_FILE" + fi + } + """.trimIndent()) + + appendLine() + appendLine("# 开始清理各种工具残留") + appendLine("TOTAL=33") + appendLine() + + val cleanupPaths = listOf( + "/data/local/stryker/" to "Stryker残留", + "/data/system/AppRetention" to "AppRetention残留", + "/data/local/tmp/luckys" to "Luck Tool残留", + "/data/local/tmp/HyperCeiler" to "西米露残留", + "/data/local/tmp/simpleHook" to "simple Hook残留", + "/data/local/tmp/DisabledAllGoogleServices" to "谷歌省电模块残留", + "/data/local/MIO" to "解包软件", + "/data/DNA" to "解包软件", + "/data/local/tmp/cleaner_starter" to "质感清理残留", + "/data/local/tmp/byyang" to "", + "/data/local/tmp/mount_mask" to "", + "/data/local/tmp/mount_mark" to "", + "/data/local/tmp/scriptTMP" to "", + "/data/local/luckys" to "", + "/data/local/tmp/horae_control.log" to "", + "/data/gpu_freq_table.conf" to "", + "/storage/emulated/0/Download/advanced/" to "", + "/storage/emulated/0/Documents/advanced/" to "爱玩机", + "/storage/emulated/0/Android/naki/" to "旧版asoulopt", + "/data/swap_config.conf" to "scene附加模块2", + "/data/local/tmp/resetprop" to "", + "/dev/cpuset/AppOpt/" to "AppOpt模块", + "/storage/emulated/0/Android/Clash/" to "Clash for Magisk模块", + "/storage/emulated/0/Android/Yume-Yunyun/" to "网易云后台优化模块", + "/data/local/tmp/Surfing_update" to "Surfing模块缓存", + "/data/encore/custom_default_cpu_gov" to "encore模块", + "/data/encore/default_cpu_gov" to "encore模块", + "/data/local/tmp/yshell" to "", + "/data/local/tmp/encore_logo.png" to "", + "/storage/emulated/legacy/" to "", + "/storage/emulated/elgg/" to "", + "/data/system/junge/" to "", + "/data/local/tmp/mount_namespace" to "挂载命名空间残留" + ) + + cleanupPaths.forEachIndexed { index, (path, desc) -> + val current = index + 1 + appendLine("cleanup_path '$path' '$desc' $current \$TOTAL") + } + + appendLine() + appendLine("echo \"$(get_current_time): 工具残留清理完成\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + + /** + * 生成post-fs-data.sh脚本内容 + */ + private fun generatePostFsDataScript(config: SuSFSManager.ModuleConfig): String { + return buildString { + appendLine("#!/system/bin/sh") + appendLine("# SuSFS Post-FS-Data Script") + appendLine("# 在文件系统挂载后但在系统完全启动前执行") + appendLine() + appendLine(generateLogSetup("susfs_post_fs_data.log")) + appendLine() + appendLine(generateBinaryCheck(config.targetPath)) + appendLine() + appendLine("echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"${'$'}LOG_FILE\"") + appendLine() + + // 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行 + if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) { + appendLine("# 设置uname和构建时间") + appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'") + appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + + generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService, config.support158) + + // 添加AVC日志欺骗设置 + generateAvcLogSpoofingSection(config.enableAvcLogSpoofing) + + appendLine("echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"${'$'}LOG_FILE\"") + } + } + + // 添加新的生成方法 + private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean, support158: Boolean) { + if (support158) { + appendLine("# 设置Zygote隔离服务卸载状态") + val umountValue = if (umountForZygoteIsoService) 1 else 0 + appendLine("\"${'$'}SUSFS_BIN\" umount_for_zygote_iso_service $umountValue") + appendLine("echo \"$(get_current_time): Zygote隔离服务卸载设置为: ${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + } + + /** + * 生成post-mount.sh脚本内容 + */ + private fun generatePostMountScript(config: SuSFSManager.ModuleConfig): String { + return buildString { + appendLine("#!/system/bin/sh") + appendLine("# SuSFS Post-Mount Script") + appendLine("# 在所有分区挂载完成后执行") + appendLine() + appendLine(generateLogSetup("susfs_post_mount.log")) + appendLine() + appendLine("echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"${'$'}LOG_FILE\"") + appendLine() + appendLine(generateBinaryCheck(config.targetPath)) + appendLine() + + // 添加SUS挂载 + if (config.susMounts.isNotEmpty()) { + appendLine("# 添加SUS挂载") + config.susMounts.forEach { mount -> + appendLine("\"${'$'}SUSFS_BIN\" add_sus_mount '$mount'") + appendLine("echo \"$(get_current_time): 添加SUS挂载: $mount\" >> \"${'$'}LOG_FILE\"") + } + appendLine() + } + + // 添加尝试卸载 + if (config.tryUmounts.isNotEmpty()) { + appendLine("# 添加尝试卸载") + config.tryUmounts.forEach { umount -> + val parts = umount.split("|") + if (parts.size == 2) { + val path = parts[0] + val mode = parts[1] + appendLine("\"${'$'}SUSFS_BIN\" add_try_umount '$path' $mode") + appendLine("echo \"$(get_current_time): 添加尝试卸载: $path (模式: $mode)\" >> \"${'$'}LOG_FILE\"") + } + } + appendLine() + } + + appendLine("echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"${'$'}LOG_FILE\"") + } + } + + /** + * 生成boot-completed.sh脚本内容 + */ + @SuppressLint("SdCardPath") + private fun generateBootCompletedScript(config: SuSFSManager.ModuleConfig): String { + return buildString { + appendLine("#!/system/bin/sh") + appendLine("# SuSFS Boot-Completed Script") + appendLine("# 在系统完全启动后执行") + appendLine() + appendLine(generateLogSetup("susfs_boot_completed.log")) + appendLine() + appendLine("echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"${'$'}LOG_FILE\"") + appendLine() + appendLine(generateBinaryCheck(config.targetPath)) + appendLine() + + // 仅在支持隐藏挂载功能时执行相关配置 + if (config.support158) { + // SUS挂载隐藏控制 + val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0 + appendLine("# 设置SUS挂载隐藏控制") + appendLine("\"${'$'}SUSFS_BIN\" hide_sus_mnts_for_all_procs $hideValue") + appendLine("echo \"$(get_current_time): SUS挂载隐藏控制设置为: ${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"${'$'}LOG_FILE\"") + appendLine() + + // 路径设置和SUS路径设置 + if (config.susPaths.isNotEmpty() || config.susLoopPaths.isNotEmpty()) { + generatePathSettingSection(config.androidDataPath, config.sdcardPath) + appendLine() + + // 添加普通SUS路径 + if (config.susPaths.isNotEmpty()) { + generateSusPathsSection(config.susPaths) + } + + // 添加循环SUS路径 + if (config.susLoopPaths.isNotEmpty()) { + generateSusLoopPathsSection(config.susLoopPaths) + } + + if (config.susMaps.isNotEmpty()) { + generateSusMapsSection(config.susMaps) + } + } + } + + appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"") + } + } + + private fun StringBuilder.generateSusMapsSection(susMaps: Set) { + if (susMaps.isNotEmpty()) { + appendLine("# 添加SUS映射") + susMaps.forEach { map -> + appendLine("\"${'$'}SUSFS_BIN\" add_sus_map '$map'") + appendLine("echo \"$(get_current_time): 添加SUS映射: $map\" >> \"${'$'}LOG_FILE\"") + } + appendLine() + } + } + + @SuppressLint("SdCardPath") + private fun StringBuilder.generatePathSettingSection(androidDataPath: String, sdcardPath: String) { + appendLine("# 路径配置") + appendLine("# 设置Android Data路径") + appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done") + appendLine("sleep 60") + appendLine() + appendLine("\"${'$'}SUSFS_BIN\" set_android_data_root_path '$androidDataPath'") + appendLine("echo \"$(get_current_time): Android Data路径设置为: $androidDataPath\" >> \"${'$'}LOG_FILE\"") + appendLine() + appendLine("# 设置SD卡路径") + appendLine("\"${'$'}SUSFS_BIN\" set_sdcard_root_path '$sdcardPath'") + appendLine("echo \"$(get_current_time): SD卡路径设置为: $sdcardPath\" >> \"${'$'}LOG_FILE\"") + appendLine() + } + + /** + * 生成module.prop文件内容 + */ + fun generateModuleProp(moduleId: String): String { + val moduleVersion = "v1.0.2" + val moduleVersionCode = "1002" + + return """ + id=$moduleId + name=SuSFS Manager + version=$moduleVersion + versionCode=$moduleVersionCode + author=ShirkNeko + description=SuSFS Manager Auto Configuration Module (自动生成请不要手动卸载或删除该模块! / Automatically generated Please do not manually uninstall or delete the module!) + updateJson= + """.trimIndent() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/theme/CardManage.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/CardManage.kt new file mode 100644 index 0000000..2757852 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/CardManage.kt @@ -0,0 +1,192 @@ +package com.sukisu.ultra.ui.theme + +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Stable +object CardConfig { + // 卡片透明度 + var cardAlpha by mutableFloatStateOf(1f) + internal set + // 卡片亮度 + var cardDim by mutableFloatStateOf(0f) + internal set + // 卡片阴影 + var cardElevation by mutableStateOf(0.dp) + internal set + + // 功能开关 + var isShadowEnabled by mutableStateOf(true) + internal set + var isCustomBackgroundEnabled by mutableStateOf(false) + internal set + + var isCustomAlphaSet by mutableStateOf(false) + internal set + var isCustomDimSet by mutableStateOf(false) + internal set + var isUserDarkModeEnabled by mutableStateOf(false) + internal set + var isUserLightModeEnabled by mutableStateOf(false) + internal set + + // 配置键名 + private object Keys { + const val CARD_ALPHA = "card_alpha" + const val CARD_DIM = "card_dim" + const val CUSTOM_BACKGROUND_ENABLED = "custom_background_enabled" + const val IS_SHADOW_ENABLED = "is_shadow_enabled" + const val IS_CUSTOM_ALPHA_SET = "is_custom_alpha_set" + const val IS_CUSTOM_DIM_SET = "is_custom_dim_set" + const val IS_USER_DARK_MODE_ENABLED = "is_user_dark_mode_enabled" + const val IS_USER_LIGHT_MODE_ENABLED = "is_user_light_mode_enabled" + } + + fun updateAlpha(alpha: Float, isCustom: Boolean = true) { + cardAlpha = alpha.coerceIn(0f, 1f) + if (isCustom) isCustomAlphaSet = true + } + + fun updateDim(dim: Float, isCustom: Boolean = true) { + cardDim = dim.coerceIn(0f, 1f) + if (isCustom) isCustomDimSet = true + } + + fun updateShadow(enabled: Boolean, elevation: Dp = cardElevation) { + isShadowEnabled = enabled + cardElevation = if (enabled) elevation else cardElevation + } + + fun updateBackground(enabled: Boolean) { + isCustomBackgroundEnabled = enabled + // 自定义背景时自动禁用阴影以获得更好的视觉效果 + if (enabled) { + updateShadow(false) + } + } + + fun updateThemePreference(darkMode: Boolean?, lightMode: Boolean?) { + isUserDarkModeEnabled = darkMode ?: false + isUserLightModeEnabled = lightMode ?: false + } + + fun reset() { + cardAlpha = 1f + cardDim = 0f + cardElevation = 0.dp + isShadowEnabled = true + isCustomBackgroundEnabled = false + isCustomAlphaSet = false + isCustomDimSet = false + isUserDarkModeEnabled = false + isUserLightModeEnabled = false + } + + fun setThemeDefaults(isDarkMode: Boolean) { + if (!isCustomAlphaSet) { + updateAlpha(if (isDarkMode) 0.88f else 1f, false) + } + if (!isCustomDimSet) { + updateDim(if (isDarkMode) 0.25f else 0f, false) + } + // 暗色模式下默认启用轻微阴影 + if (isDarkMode && !isCustomBackgroundEnabled) { + updateShadow(true, 2.dp) + } + } + + fun save(context: Context) { + val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE) + prefs.edit().apply { + putFloat(Keys.CARD_ALPHA, cardAlpha) + putFloat(Keys.CARD_DIM, cardDim) + putBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, isCustomBackgroundEnabled) + putBoolean(Keys.IS_SHADOW_ENABLED, isShadowEnabled) + putBoolean(Keys.IS_CUSTOM_ALPHA_SET, isCustomAlphaSet) + putBoolean(Keys.IS_CUSTOM_DIM_SET, isCustomDimSet) + putBoolean(Keys.IS_USER_DARK_MODE_ENABLED, isUserDarkModeEnabled) + putBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, isUserLightModeEnabled) + apply() + } + } + + fun load(context: Context) { + val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE) + cardAlpha = prefs.getFloat(Keys.CARD_ALPHA, 1f).coerceIn(0f, 1f) + cardDim = prefs.getFloat(Keys.CARD_DIM, 0f).coerceIn(0f, 1f) + isCustomBackgroundEnabled = prefs.getBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, false) + isShadowEnabled = prefs.getBoolean(Keys.IS_SHADOW_ENABLED, true) + isCustomAlphaSet = prefs.getBoolean(Keys.IS_CUSTOM_ALPHA_SET, false) + isCustomDimSet = prefs.getBoolean(Keys.IS_CUSTOM_DIM_SET, false) + isUserDarkModeEnabled = prefs.getBoolean(Keys.IS_USER_DARK_MODE_ENABLED, false) + isUserLightModeEnabled = prefs.getBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, false) + + // 应用阴影设置 + updateShadow(isShadowEnabled, if (isShadowEnabled) cardElevation else 0.dp) + } + + @Deprecated("使用 updateShadow 替代", ReplaceWith("updateShadow(enabled)")) + fun updateShadowEnabled(enabled: Boolean) { + updateShadow(enabled) + } +} + +object CardStyleProvider { + + @Composable + fun getCardColors(originalColor: Color) = CardDefaults.cardColors( + containerColor = originalColor.copy(alpha = CardConfig.cardAlpha), + contentColor = determineContentColor(originalColor), + disabledContainerColor = originalColor.copy(alpha = CardConfig.cardAlpha * 0.38f), + disabledContentColor = determineContentColor(originalColor).copy(alpha = 0.38f) + ) + + @Composable + fun getCardElevation() = CardDefaults.cardElevation( + defaultElevation = CardConfig.cardElevation, + pressedElevation = if (CardConfig.isShadowEnabled) { + (CardConfig.cardElevation.value + 0).dp + } else 0.dp, + focusedElevation = if (CardConfig.isShadowEnabled) { + (CardConfig.cardElevation.value + 0).dp + } else 0.dp, + hoveredElevation = if (CardConfig.isShadowEnabled) { + (CardConfig.cardElevation.value + 0).dp + } else 0.dp, + draggedElevation = if (CardConfig.isShadowEnabled) { + (CardConfig.cardElevation.value + 0).dp + } else 0.dp, + disabledElevation = 0.dp + ) + + @Composable + private fun determineContentColor(originalColor: Color): Color { + val isDarkTheme = isSystemInDarkTheme() + + return when { + ThemeConfig.isThemeChanging -> { + if (isDarkTheme) Color.White else Color.Black + } + CardConfig.isUserLightModeEnabled -> Color.Black + CardConfig.isUserDarkModeEnabled -> Color.White + else -> { + val luminance = originalColor.luminance() + val threshold = if (isDarkTheme) 0.4f else 0.6f + if (luminance > threshold) Color.Black else Color.White + } + } + } +} + +// 向后兼容 +@Composable +fun getCardColors(originalColor: Color) = CardStyleProvider.getCardColors(originalColor) + +@Composable +fun getCardElevation() = CardStyleProvider.getCardElevation() diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Color.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Color.kt new file mode 100644 index 0000000..52d1367 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Color.kt @@ -0,0 +1,615 @@ +package com.sukisu.ultra.ui.theme + +import androidx.compose.ui.graphics.Color + +sealed class ThemeColors { + // 浅色 + abstract val primaryLight: Color + abstract val onPrimaryLight: Color + abstract val primaryContainerLight: Color + abstract val onPrimaryContainerLight: Color + abstract val secondaryLight: Color + abstract val onSecondaryLight: Color + abstract val secondaryContainerLight: Color + abstract val onSecondaryContainerLight: Color + abstract val tertiaryLight: Color + abstract val onTertiaryLight: Color + abstract val tertiaryContainerLight: Color + abstract val onTertiaryContainerLight: Color + abstract val errorLight: Color + abstract val onErrorLight: Color + abstract val errorContainerLight: Color + abstract val onErrorContainerLight: Color + abstract val backgroundLight: Color + abstract val onBackgroundLight: Color + abstract val surfaceLight: Color + abstract val onSurfaceLight: Color + abstract val surfaceVariantLight: Color + abstract val onSurfaceVariantLight: Color + abstract val outlineLight: Color + abstract val outlineVariantLight: Color + abstract val scrimLight: Color + abstract val inverseSurfaceLight: Color + abstract val inverseOnSurfaceLight: Color + abstract val inversePrimaryLight: Color + abstract val surfaceDimLight: Color + abstract val surfaceBrightLight: Color + abstract val surfaceContainerLowestLight: Color + abstract val surfaceContainerLowLight: Color + abstract val surfaceContainerLight: Color + abstract val surfaceContainerHighLight: Color + abstract val surfaceContainerHighestLight: Color + // 深色 + abstract val primaryDark: Color + abstract val onPrimaryDark: Color + abstract val primaryContainerDark: Color + abstract val onPrimaryContainerDark: Color + abstract val secondaryDark: Color + abstract val onSecondaryDark: Color + abstract val secondaryContainerDark: Color + abstract val onSecondaryContainerDark: Color + abstract val tertiaryDark: Color + abstract val onTertiaryDark: Color + abstract val tertiaryContainerDark: Color + abstract val onTertiaryContainerDark: Color + abstract val errorDark: Color + abstract val onErrorDark: Color + abstract val errorContainerDark: Color + abstract val onErrorContainerDark: Color + abstract val backgroundDark: Color + abstract val onBackgroundDark: Color + abstract val surfaceDark: Color + abstract val onSurfaceDark: Color + abstract val surfaceVariantDark: Color + abstract val onSurfaceVariantDark: Color + abstract val outlineDark: Color + abstract val outlineVariantDark: Color + abstract val scrimDark: Color + abstract val inverseSurfaceDark: Color + abstract val inverseOnSurfaceDark: Color + abstract val inversePrimaryDark: Color + abstract val surfaceDimDark: Color + abstract val surfaceBrightDark: Color + abstract val surfaceContainerLowestDark: Color + abstract val surfaceContainerLowDark: Color + abstract val surfaceContainerDark: Color + abstract val surfaceContainerHighDark: Color + abstract val surfaceContainerHighestDark: Color + + // 默认主题 (蓝色) + object Default : ThemeColors() { + override val primaryLight = Color(0xFF415F91) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFFD6E3FF) + override val onPrimaryContainerLight = Color(0xFF284777) + override val secondaryLight = Color(0xFF565F71) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFDAE2F9) + override val onSecondaryContainerLight = Color(0xFF3E4759) + override val tertiaryLight = Color(0xFF705575) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFFFAD8FD) + override val onTertiaryContainerLight = Color(0xFF573E5C) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFF9F9FF) + override val onBackgroundLight = Color(0xFF191C20) + override val surfaceLight = Color(0xFFF9F9FF) + override val onSurfaceLight = Color(0xFF191C20) + override val surfaceVariantLight = Color(0xFFE0E2EC) + override val onSurfaceVariantLight = Color(0xFF44474E) + override val outlineLight = Color(0xFF74777F) + override val outlineVariantLight = Color(0xFFC4C6D0) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF2E3036) + override val inverseOnSurfaceLight = Color(0xFFF0F0F7) + override val inversePrimaryLight = Color(0xFFAAC7FF) + override val surfaceDimLight = Color(0xFFD9D9E0) + override val surfaceBrightLight = Color(0xFFF9F9FF) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFF3F3FA) + override val surfaceContainerLight = Color(0xFFEDEDF4) + override val surfaceContainerHighLight = Color(0xFFE7E8EE) + override val surfaceContainerHighestLight = Color(0xFFE2E2E9) + + override val primaryDark = Color(0xFFAAC7FF) + override val onPrimaryDark = Color(0xFF0A305F) + override val primaryContainerDark = Color(0xFF284777) + override val onPrimaryContainerDark = Color(0xFFD6E3FF) + override val secondaryDark = Color(0xFFBEC6DC) + override val onSecondaryDark = Color(0xFF283141) + override val secondaryContainerDark = Color(0xFF3E4759) + override val onSecondaryContainerDark = Color(0xFFDAE2F9) + override val tertiaryDark = Color(0xFFDDBCE0) + override val onTertiaryDark = Color(0xFF3F2844) + override val tertiaryContainerDark = Color(0xFF573E5C) + override val onTertiaryContainerDark = Color(0xFFFAD8FD) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF111318) + override val onBackgroundDark = Color(0xFFE2E2E9) + override val surfaceDark = Color(0xFF111318) + override val onSurfaceDark = Color(0xFFE2E2E9) + override val surfaceVariantDark = Color(0xFF44474E) + override val onSurfaceVariantDark = Color(0xFFC4C6D0) + override val outlineDark = Color(0xFF8E9099) + override val outlineVariantDark = Color(0xFF44474E) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFE2E2E9) + override val inverseOnSurfaceDark = Color(0xFF2E3036) + override val inversePrimaryDark = Color(0xFF415F91) + override val surfaceDimDark = Color(0xFF111318) + override val surfaceBrightDark = Color(0xFF37393E) + override val surfaceContainerLowestDark = Color(0xFF0C0E13) + override val surfaceContainerLowDark = Color(0xFF191C20) + override val surfaceContainerDark = Color(0xFF1D2024) + override val surfaceContainerHighDark = Color(0xFF282A2F) + override val surfaceContainerHighestDark = Color(0xFF33353A) + } + + // 绿色主题 + object Green : ThemeColors() { + override val primaryLight = Color(0xFF4C662B) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFFCDEDA3) + override val onPrimaryContainerLight = Color(0xFF354E16) + override val secondaryLight = Color(0xFF586249) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFDCE7C8) + override val onSecondaryContainerLight = Color(0xFF404A33) + override val tertiaryLight = Color(0xFF386663) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFFBCECE7) + override val onTertiaryContainerLight = Color(0xFF1F4E4B) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFF9FAEF) + override val onBackgroundLight = Color(0xFF1A1C16) + override val surfaceLight = Color(0xFFF9FAEF) + override val onSurfaceLight = Color(0xFF1A1C16) + override val surfaceVariantLight = Color(0xFFE1E4D5) + override val onSurfaceVariantLight = Color(0xFF44483D) + override val outlineLight = Color(0xFF75796C) + override val outlineVariantLight = Color(0xFFC5C8BA) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF2F312A) + override val inverseOnSurfaceLight = Color(0xFFF1F2E6) + override val inversePrimaryLight = Color(0xFFB1D18A) + override val surfaceDimLight = Color(0xFFDADBD0) + override val surfaceBrightLight = Color(0xFFF9FAEF) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFF3F4E9) + override val surfaceContainerLight = Color(0xFFEEEFE3) + override val surfaceContainerHighLight = Color(0xFFE8E9DE) + override val surfaceContainerHighestLight = Color(0xFFE2E3D8) + + override val primaryDark = Color(0xFFB1D18A) + override val onPrimaryDark = Color(0xFF1F3701) + override val primaryContainerDark = Color(0xFF354E16) + override val onPrimaryContainerDark = Color(0xFFCDEDA3) + override val secondaryDark = Color(0xFFBFCBAD) + override val onSecondaryDark = Color(0xFF2A331E) + override val secondaryContainerDark = Color(0xFF404A33) + override val onSecondaryContainerDark = Color(0xFFDCE7C8) + override val tertiaryDark = Color(0xFFA0D0CB) + override val onTertiaryDark = Color(0xFF003735) + override val tertiaryContainerDark = Color(0xFF1F4E4B) + override val onTertiaryContainerDark = Color(0xFFBCECE7) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF12140E) + override val onBackgroundDark = Color(0xFFE2E3D8) + override val surfaceDark = Color(0xFF12140E) + override val onSurfaceDark = Color(0xFFE2E3D8) + override val surfaceVariantDark = Color(0xFF44483D) + override val onSurfaceVariantDark = Color(0xFFC5C8BA) + override val outlineDark = Color(0xFF8F9285) + override val outlineVariantDark = Color(0xFF44483D) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFE2E3D8) + override val inverseOnSurfaceDark = Color(0xFF2F312A) + override val inversePrimaryDark = Color(0xFF4C662B) + override val surfaceDimDark = Color(0xFF12140E) + override val surfaceBrightDark = Color(0xFF383A32) + override val surfaceContainerLowestDark = Color(0xFF0C0F09) + override val surfaceContainerLowDark = Color(0xFF1A1C16) + override val surfaceContainerDark = Color(0xFF1E201A) + override val surfaceContainerHighDark = Color(0xFF282B24) + override val surfaceContainerHighestDark = Color(0xFF33362E) + } + + // 紫色主题 + object Purple : ThemeColors() { + override val primaryLight = Color(0xFF7C4E7E) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFFFFD6FC) + override val onPrimaryContainerLight = Color(0xFF623765) + override val secondaryLight = Color(0xFF6C586B) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFF5DBF1) + override val onSecondaryContainerLight = Color(0xFF534152) + override val tertiaryLight = Color(0xFF825249) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFFFFDAD4) + override val onTertiaryContainerLight = Color(0xFF673B33) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFFFF7FA) + override val onBackgroundLight = Color(0xFF1F1A1F) + override val surfaceLight = Color(0xFFFFF7FA) + override val onSurfaceLight = Color(0xFF1F1A1F) + override val surfaceVariantLight = Color(0xFFEDDFE8) + override val onSurfaceVariantLight = Color(0xFF4D444C) + override val outlineLight = Color(0xFF7F747C) + override val outlineVariantLight = Color(0xFFD0C3CC) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF352F34) + override val inverseOnSurfaceLight = Color(0xFFF9EEF4) + override val inversePrimaryLight = Color(0xFFECB4EC) + override val surfaceDimLight = Color(0xFFE2D7DE) + override val surfaceBrightLight = Color(0xFFFFF7FA) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFFCF0F7) + override val surfaceContainerLight = Color(0xFFF6EBF2) + override val surfaceContainerHighLight = Color(0xFFF0E5EC) + override val surfaceContainerHighestLight = Color(0xFFEBDFE6) + + override val primaryDark = Color(0xFFECB4EC) + override val onPrimaryDark = Color(0xFF49204D) + override val primaryContainerDark = Color(0xFF623765) + override val onPrimaryContainerDark = Color(0xFFFFD6FC) + override val secondaryDark = Color(0xFFD8BFD5) + override val onSecondaryDark = Color(0xFF3B2B3B) + override val secondaryContainerDark = Color(0xFF534152) + override val onSecondaryContainerDark = Color(0xFFF5DBF1) + override val tertiaryDark = Color(0xFFF6B8AD) + override val onTertiaryDark = Color(0xFF4C251F) + override val tertiaryContainerDark = Color(0xFF673B33) + override val onTertiaryContainerDark = Color(0xFFFFDAD4) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF171216) + override val onBackgroundDark = Color(0xFFEBDFE6) + override val surfaceDark = Color(0xFF171216) + override val onSurfaceDark = Color(0xFFEBDFE6) + override val surfaceVariantDark = Color(0xFF4D444C) + override val onSurfaceVariantDark = Color(0xFFD0C3CC) + override val outlineDark = Color(0xFF998D96) + override val outlineVariantDark = Color(0xFF4D444C) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFEBDFE6) + override val inverseOnSurfaceDark = Color(0xFF352F34) + override val inversePrimaryDark = Color(0xFF7C4E7E) + override val surfaceDimDark = Color(0xFF171216) + override val surfaceBrightDark = Color(0xFF3E373D) + override val surfaceContainerLowestDark = Color(0xFF110D11) + override val surfaceContainerLowDark = Color(0xFF1F1A1F) + override val surfaceContainerDark = Color(0xFF231E23) + override val surfaceContainerHighDark = Color(0xFF2E282D) + override val surfaceContainerHighestDark = Color(0xFF393338) + } + + // 橙色主题 + object Orange : ThemeColors() { + override val primaryLight = Color(0xFF8B4F24) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFFFFDCC7) + override val onPrimaryContainerLight = Color(0xFF6E390E) + override val secondaryLight = Color(0xFF755846) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFFFDCC7) + override val onSecondaryContainerLight = Color(0xFF5B4130) + override val tertiaryLight = Color(0xFF865219) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFFFFDCBF) + override val onTertiaryContainerLight = Color(0xFF6A3B01) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFFFF8F5) + override val onBackgroundLight = Color(0xFF221A15) + override val surfaceLight = Color(0xFFFFF8F5) + override val onSurfaceLight = Color(0xFF221A15) + override val surfaceVariantLight = Color(0xFFF4DED3) + override val onSurfaceVariantLight = Color(0xFF52443C) + override val outlineLight = Color(0xFF84746A) + override val outlineVariantLight = Color(0xFFD7C3B8) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF382E29) + override val inverseOnSurfaceLight = Color(0xFFFFEDE5) + override val inversePrimaryLight = Color(0xFFFFB787) + override val surfaceDimLight = Color(0xFFE7D7CE) + override val surfaceBrightLight = Color(0xFFFFF8F5) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFFFF1EA) + override val surfaceContainerLight = Color(0xFFFCEBE2) + override val surfaceContainerHighLight = Color(0xFFF6E5DC) + override val surfaceContainerHighestLight = Color(0xFFF0DFD7) + + override val primaryDark = Color(0xFFFFB787) + override val onPrimaryDark = Color(0xFF502400) + override val primaryContainerDark = Color(0xFF6E390E) + override val onPrimaryContainerDark = Color(0xFFFFDCC7) + override val secondaryDark = Color(0xFFE5BFA8) + override val onSecondaryDark = Color(0xFF422B1B) + override val secondaryContainerDark = Color(0xFF5B4130) + override val onSecondaryContainerDark = Color(0xFFFFDCC7) + override val tertiaryDark = Color(0xFFFDB876) + override val onTertiaryDark = Color(0xFF4B2800) + override val tertiaryContainerDark = Color(0xFF6A3B01) + override val onTertiaryContainerDark = Color(0xFFFFDCBF) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF19120D) + override val onBackgroundDark = Color(0xFFF0DFD7) + override val surfaceDark = Color(0xFF19120D) + override val onSurfaceDark = Color(0xFFF0DFD7) + override val surfaceVariantDark = Color(0xFF52443C) + override val onSurfaceVariantDark = Color(0xFFD7C3B8) + override val outlineDark = Color(0xFF9F8D83) + override val outlineVariantDark = Color(0xFF52443C) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFF0DFD7) + override val inverseOnSurfaceDark = Color(0xFF382E29) + override val inversePrimaryDark = Color(0xFF8B4F24) + override val surfaceDimDark = Color(0xFF19120D) + override val surfaceBrightDark = Color(0xFF413731) + override val surfaceContainerLowestDark = Color(0xFF140D08) + override val surfaceContainerLowDark = Color(0xFF221A15) + override val surfaceContainerDark = Color(0xFF261E19) + override val surfaceContainerHighDark = Color(0xFF312823) + override val surfaceContainerHighestDark = Color(0xFF3D332D) + } + + // 粉色主题 + object Pink : ThemeColors() { + override val primaryLight = Color(0xFF8C4A60) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFFFFD9E2) + override val onPrimaryContainerLight = Color(0xFF703348) + override val secondaryLight = Color(0xFF8B4A62) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFFFD9E3) + override val onSecondaryContainerLight = Color(0xFF6F334B) + override val tertiaryLight = Color(0xFF8B4A62) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFFFFD9E3) + override val onTertiaryContainerLight = Color(0xFF6F334B) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFFFF8F8) + override val onBackgroundLight = Color(0xFF22191B) + override val surfaceLight = Color(0xFFFFF8F8) + override val onSurfaceLight = Color(0xFF22191B) + override val surfaceVariantLight = Color(0xFFF2DDE1) + override val onSurfaceVariantLight = Color(0xFF514346) + override val outlineLight = Color(0xFF837377) + override val outlineVariantLight = Color(0xFFD5C2C5) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF372E30) + override val inverseOnSurfaceLight = Color(0xFFFDEDEF) + override val inversePrimaryLight = Color(0xFFFFB1C7) + override val surfaceDimLight = Color(0xFFE6D6D9) + override val surfaceBrightLight = Color(0xFFFFF8F8) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFFFF0F2) + override val surfaceContainerLight = Color(0xFFFBEAED) + override val surfaceContainerHighLight = Color(0xFFF5E4E7) + override val surfaceContainerHighestLight = Color(0xFFEFDFE1) + + override val primaryDark = Color(0xFFFFB1C7) + override val onPrimaryDark = Color(0xFF541D32) + override val primaryContainerDark = Color(0xFF703348) + override val onPrimaryContainerDark = Color(0xFFFFD9E2) + override val secondaryDark = Color(0xFFFFB0CB) + override val onSecondaryDark = Color(0xFF541D34) + override val secondaryContainerDark = Color(0xFF6F334B) + override val onSecondaryContainerDark = Color(0xFFFFD9E3) + override val tertiaryDark = Color(0xFFFFB0CB) + override val onTertiaryDark = Color(0xFF541D34) + override val tertiaryContainerDark = Color(0xFF6F334B) + override val onTertiaryContainerDark = Color(0xFFFFD9E3) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF191113) + override val onBackgroundDark = Color(0xFFEFDFE1) + override val surfaceDark = Color(0xFF191113) + override val onSurfaceDark = Color(0xFFEFDFE1) + override val surfaceVariantDark = Color(0xFF514346) + override val onSurfaceVariantDark = Color(0xFFD5C2C5) + override val outlineDark = Color(0xFF9E8C90) + override val outlineVariantDark = Color(0xFF514346) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFEFDFE1) + override val inverseOnSurfaceDark = Color(0xFF372E30) + override val inversePrimaryDark = Color(0xFF8C4A60) + override val surfaceDimDark = Color(0xFF191113) + override val surfaceBrightDark = Color(0xFF413739) + override val surfaceContainerLowestDark = Color(0xFF140C0E) + override val surfaceContainerLowDark = Color(0xFF22191B) + override val surfaceContainerDark = Color(0xFF261D1F) + override val surfaceContainerHighDark = Color(0xFF31282A) + override val surfaceContainerHighestDark = Color(0xFF3C3234) + } + + // 灰色主题 + object Gray : ThemeColors() { + override val primaryLight = Color(0xFF5B5C5C) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFF747474) + override val onPrimaryContainerLight = Color(0xFFFEFCFC) + override val secondaryLight = Color(0xFF5F5E5E) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFE4E2E1) + override val onSecondaryContainerLight = Color(0xFF656464) + override val tertiaryLight = Color(0xFF5E5B5D) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFF777375) + override val onTertiaryContainerLight = Color(0xFFFFFBFF) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFFCF8F8) + override val onBackgroundLight = Color(0xFF1C1B1B) + override val surfaceLight = Color(0xFFFCF8F8) + override val onSurfaceLight = Color(0xFF1C1B1B) + override val surfaceVariantLight = Color(0xFFE0E3E3) + override val onSurfaceVariantLight = Color(0xFF444748) + override val outlineLight = Color(0xFF747878) + override val outlineVariantLight = Color(0xFFC4C7C7) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF313030) + override val inverseOnSurfaceLight = Color(0xFFF4F0EF) + override val inversePrimaryLight = Color(0xFFC7C6C6) + override val surfaceDimLight = Color(0xFFDDD9D8) + override val surfaceBrightLight = Color(0xFFFCF8F8) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFF7F3F2) + override val surfaceContainerLight = Color(0xFFF1EDEC) + override val surfaceContainerHighLight = Color(0xFFEBE7E7) + override val surfaceContainerHighestLight = Color(0xFFE5E2E1) + + override val primaryDark = Color(0xFFC7C6C6) + override val onPrimaryDark = Color(0xFF303031) + override val primaryContainerDark = Color(0xFF919190) + override val onPrimaryContainerDark = Color(0xFF161718) + override val secondaryDark = Color(0xFFC8C6C5) + override val onSecondaryDark = Color(0xFF303030) + override val secondaryContainerDark = Color(0xFF474746) + override val onSecondaryContainerDark = Color(0xFFB7B5B4) + override val tertiaryDark = Color(0xFFCAC5C7) + override val onTertiaryDark = Color(0xFF323031) + override val tertiaryContainerDark = Color(0xFF948F91) + override val onTertiaryContainerDark = Color(0xFF181718) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF141313) + override val onBackgroundDark = Color(0xFFE5E2E1) + override val surfaceDark = Color(0xFF141313) + override val onSurfaceDark = Color(0xFFE5E2E1) + override val surfaceVariantDark = Color(0xFF444748) + override val onSurfaceVariantDark = Color(0xFFC4C7C7) + override val outlineDark = Color(0xFF8E9192) + override val outlineVariantDark = Color(0xFF444748) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFE5E2E1) + override val inverseOnSurfaceDark = Color(0xFF313030) + override val inversePrimaryDark = Color(0xFF5E5E5E) + override val surfaceDimDark = Color(0xFF141313) + override val surfaceBrightDark = Color(0xFF3A3939) + override val surfaceContainerLowestDark = Color(0xFF0E0E0E) + override val surfaceContainerLowDark = Color(0xFF1C1B1B) + override val surfaceContainerDark = Color(0xFF201F1F) + override val surfaceContainerHighDark = Color(0xFF2A2A2A) + override val surfaceContainerHighestDark = Color(0xFF353434) + } + + // 黄色主题 + object Yellow : ThemeColors() { + override val primaryLight = Color(0xFF6D5E0F) + override val onPrimaryLight = Color(0xFFFFFFFF) + override val primaryContainerLight = Color(0xFFF8E288) + override val onPrimaryContainerLight = Color(0xFF534600) + override val secondaryLight = Color(0xFF6D5E0F) + override val onSecondaryLight = Color(0xFFFFFFFF) + override val secondaryContainerLight = Color(0xFFF7E388) + override val onSecondaryContainerLight = Color(0xFF534600) + override val tertiaryLight = Color(0xFF685F13) + override val onTertiaryLight = Color(0xFFFFFFFF) + override val tertiaryContainerLight = Color(0xFFF1E58A) + override val onTertiaryContainerLight = Color(0xFF4F4800) + override val errorLight = Color(0xFFBA1A1A) + override val onErrorLight = Color(0xFFFFFFFF) + override val errorContainerLight = Color(0xFFFFDAD6) + override val onErrorContainerLight = Color(0xFF93000A) + override val backgroundLight = Color(0xFFFFF9ED) + override val onBackgroundLight = Color(0xFF1E1C13) + override val surfaceLight = Color(0xFFFFF9ED) + override val onSurfaceLight = Color(0xFF1E1C13) + override val surfaceVariantLight = Color(0xFFE9E2D0) + override val onSurfaceVariantLight = Color(0xFF4B4739) + override val outlineLight = Color(0xFF7C7768) + override val outlineVariantLight = Color(0xFFCDC6B4) + override val scrimLight = Color(0xFF000000) + override val inverseSurfaceLight = Color(0xFF333027) + override val inverseOnSurfaceLight = Color(0xFFF7F0E2) + override val inversePrimaryLight = Color(0xFFDAC66F) + override val surfaceDimLight = Color(0xFFE0D9CC) + override val surfaceBrightLight = Color(0xFFFFF9ED) + override val surfaceContainerLowestLight = Color(0xFFFFFFFF) + override val surfaceContainerLowLight = Color(0xFFFAF3E5) + override val surfaceContainerLight = Color(0xFFF4EDDF) + override val surfaceContainerHighLight = Color(0xFFEEE8DA) + override val surfaceContainerHighestLight = Color(0xFFE8E2D4) + + override val primaryDark = Color(0xFFDAC66F) + override val onPrimaryDark = Color(0xFF393000) + override val primaryContainerDark = Color(0xFF534600) + override val onPrimaryContainerDark = Color(0xFFF8E288) + override val secondaryDark = Color(0xFFDAC76F) + override val onSecondaryDark = Color(0xFF393000) + override val secondaryContainerDark = Color(0xFF534600) + override val onSecondaryContainerDark = Color(0xFFF7E388) + override val tertiaryDark = Color(0xFFD4C871) + override val onTertiaryDark = Color(0xFF363100) + override val tertiaryContainerDark = Color(0xFF4F4800) + override val onTertiaryContainerDark = Color(0xFFF1E58A) + override val errorDark = Color(0xFFFFB4AB) + override val onErrorDark = Color(0xFF690005) + override val errorContainerDark = Color(0xFF93000A) + override val onErrorContainerDark = Color(0xFFFFDAD6) + override val backgroundDark = Color(0xFF15130B) + override val onBackgroundDark = Color(0xFFE8E2D4) + override val surfaceDark = Color(0xFF15130B) + override val onSurfaceDark = Color(0xFFE8E2D4) + override val surfaceVariantDark = Color(0xFF4B4739) + override val onSurfaceVariantDark = Color(0xFFCDC6B4) + override val outlineDark = Color(0xFF969080) + override val outlineVariantDark = Color(0xFF4B4739) + override val scrimDark = Color(0xFF000000) + override val inverseSurfaceDark = Color(0xFFE8E2D4) + override val inverseOnSurfaceDark = Color(0xFF333027) + override val inversePrimaryDark = Color(0xFF6D5E0F) + override val surfaceDimDark = Color(0xFF15130B) + override val surfaceBrightDark = Color(0xFF3C3930) + override val surfaceContainerLowestDark = Color(0xFF100E07) + override val surfaceContainerLowDark = Color(0xFF1E1C13) + override val surfaceContainerDark = Color(0xFF222017) + override val surfaceContainerHighDark = Color(0xFF2C2A21) + override val surfaceContainerHighestDark = Color(0xFF37352B) + } + + companion object { + fun fromName(name: String): ThemeColors = when (name.lowercase()) { + "green" -> Green + "purple" -> Purple + "orange" -> Orange + "pink" -> Pink + "gray" -> Gray + "yellow" -> Yellow + else -> Default + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Theme.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Theme.kt new file mode 100644 index 0000000..87ac86d --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Theme.kt @@ -0,0 +1,593 @@ +package com.sukisu.ultra.ui.theme + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.zIndex +import androidx.core.content.edit +import androidx.core.net.toUri +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import com.sukisu.ultra.ui.theme.util.BackgroundTransformation +import com.sukisu.ultra.ui.theme.util.saveTransformedBackground +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream + +@Stable +object ThemeConfig { + // 主题状态 + var customBackgroundUri by mutableStateOf(null) + var forceDarkMode by mutableStateOf(null) + var currentTheme by mutableStateOf(ThemeColors.Default) + var useDynamicColor by mutableStateOf(false) + + // 背景状态 + var backgroundImageLoaded by mutableStateOf(false) + var isThemeChanging by mutableStateOf(false) + var preventBackgroundRefresh by mutableStateOf(false) + + // 主题变化检测 + private var lastDarkModeState: Boolean? = null + + fun detectThemeChange(currentDarkMode: Boolean): Boolean { + val hasChanged = lastDarkModeState != null && lastDarkModeState != currentDarkMode + lastDarkModeState = currentDarkMode + return hasChanged + } + + fun resetBackgroundState() { + if (!preventBackgroundRefresh) { + backgroundImageLoaded = false + } + isThemeChanging = true + } + + fun updateTheme( + theme: ThemeColors? = null, + dynamicColor: Boolean? = null, + darkMode: Boolean? = null + ) { + theme?.let { currentTheme = it } + dynamicColor?.let { useDynamicColor = it } + darkMode?.let { forceDarkMode = it } + } + + fun reset() { + customBackgroundUri = null + forceDarkMode = null + currentTheme = ThemeColors.Default + useDynamicColor = false + backgroundImageLoaded = false + isThemeChanging = false + preventBackgroundRefresh = false + lastDarkModeState = null + } +} + +object ThemeManager { + private const val PREFS_NAME = "theme_prefs" + + fun saveThemeMode(context: Context, forceDark: Boolean?) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { + putString("theme_mode", when (forceDark) { + true -> "dark" + false -> "light" + null -> "system" + }) + } + ThemeConfig.forceDarkMode = forceDark + } + + fun loadThemeMode(context: Context) { + val mode = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString("theme_mode", "system") + + ThemeConfig.forceDarkMode = when (mode) { + "dark" -> true + "light" -> false + else -> null + } + } + + fun saveThemeColors(context: Context, themeName: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { + putString("theme_colors", themeName) + } + ThemeConfig.currentTheme = ThemeColors.fromName(themeName) + } + + fun loadThemeColors(context: Context) { + val themeName = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString("theme_colors", "default") ?: "default" + ThemeConfig.currentTheme = ThemeColors.fromName(themeName) + } + + fun saveDynamicColorState(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { + putBoolean("use_dynamic_color", enabled) + } + ThemeConfig.useDynamicColor = enabled + } + + + fun loadDynamicColorState(context: Context) { + val enabled = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean("use_dynamic_color", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + ThemeConfig.useDynamicColor = enabled + } +} + +object BackgroundManager { + private const val TAG = "BackgroundManager" + + fun saveAndApplyCustomBackground( + context: Context, + uri: Uri, + transformation: BackgroundTransformation? = null + ) { + try { + val finalUri = if (transformation != null) { + context.saveTransformedBackground(uri, transformation) + } else { + copyImageToInternalStorage(context, uri) + } + + saveBackgroundUri(context, finalUri) + ThemeConfig.customBackgroundUri = finalUri + CardConfig.updateBackground(true) + resetBackgroundState(context) + + } catch (e: Exception) { + Log.e(TAG, "保存背景失败: ${e.message}", e) + } + } + + fun clearCustomBackground(context: Context) { + saveBackgroundUri(context, null) + ThemeConfig.customBackgroundUri = null + CardConfig.updateBackground(false) + resetBackgroundState(context) + } + + fun loadCustomBackground(context: Context) { + val uriString = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE) + .getString("custom_background", null) + + val newUri = uriString?.toUri() + val preventRefresh = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE) + .getBoolean("prevent_background_refresh", false) + + ThemeConfig.preventBackgroundRefresh = preventRefresh + + if (!preventRefresh || ThemeConfig.customBackgroundUri?.toString() != newUri?.toString()) { + Log.d(TAG, "加载自定义背景: $uriString") + ThemeConfig.customBackgroundUri = newUri + ThemeConfig.backgroundImageLoaded = false + CardConfig.updateBackground(newUri != null) + } + } + + private fun saveBackgroundUri(context: Context, uri: Uri?) { + context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit { + putString("custom_background", uri?.toString()) + putBoolean("prevent_background_refresh", false) + } + } + + private fun resetBackgroundState(context: Context) { + ThemeConfig.backgroundImageLoaded = false + ThemeConfig.preventBackgroundRefresh = false + context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit { + putBoolean("prevent_background_refresh", false) + } + } + + private fun copyImageToInternalStorage(context: Context, uri: Uri): Uri? { + return try { + val inputStream = context.contentResolver.openInputStream(uri) ?: return null + val fileName = "custom_background_${System.currentTimeMillis()}.jpg" + val file = File(context.filesDir, fileName) + + FileOutputStream(file).use { outputStream -> + val buffer = ByteArray(8 * 1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + } + outputStream.flush() + } + inputStream.close() + + Uri.fromFile(file) + } catch (e: Exception) { + Log.e(TAG, "复制图片失败: ${e.message}", e) + null + } + } +} + +@Composable +fun KernelSUTheme( + darkTheme: Boolean = when(ThemeConfig.forceDarkMode) { + true -> true + false -> false + null -> isSystemInDarkTheme() + }, + dynamicColor: Boolean = ThemeConfig.useDynamicColor, + content: @Composable () -> Unit +) { + val context = LocalContext.current + val systemIsDark = isSystemInDarkTheme() + + // 初始化主题 + ThemeInitializer(context = context, systemIsDark = systemIsDark) + + // 创建颜色方案 + val colorScheme = createColorScheme(context, darkTheme, dynamicColor) + + // 系统栏样式 + SystemBarController(darkTheme) + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography + ) { + Box(modifier = Modifier.fillMaxSize()) { + // 背景层 + BackgroundLayer(darkTheme) + // 内容层 + Box(modifier = Modifier.fillMaxSize().zIndex(1f)) { + content() + } + } + } +} + +@Composable +private fun ThemeInitializer(context: Context, systemIsDark: Boolean) { + val themeChanged = ThemeConfig.detectThemeChange(systemIsDark) + val scope = rememberCoroutineScope() + + // 处理系统主题变化 + LaunchedEffect(systemIsDark, themeChanged) { + if (ThemeConfig.forceDarkMode == null && themeChanged) { + Log.d("ThemeSystem", "系统主题变化: $systemIsDark") + ThemeConfig.resetBackgroundState() + + if (!ThemeConfig.preventBackgroundRefresh) { + BackgroundManager.loadCustomBackground(context) + } + + CardConfig.apply { + load(context) + setThemeDefaults(systemIsDark) + save(context) + } + } + } + + // 初始加载配置 + LaunchedEffect(Unit) { + scope.launch { + ThemeManager.loadThemeMode(context) + ThemeManager.loadThemeColors(context) + ThemeManager.loadDynamicColorState(context) + CardConfig.load(context) + + if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) { + BackgroundManager.loadCustomBackground(context) + } + } + } +} + +@Composable +private fun BackgroundLayer(darkTheme: Boolean) { + val backgroundUri = rememberSaveable { mutableStateOf(ThemeConfig.customBackgroundUri) } + + LaunchedEffect(ThemeConfig.customBackgroundUri) { + backgroundUri.value = ThemeConfig.customBackgroundUri + } + + // 默认背景 + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(-2f) + .background( + if (CardConfig.isCustomBackgroundEnabled) { + MaterialTheme.colorScheme.surfaceContainerLow + } else { + MaterialTheme.colorScheme.background + } + ) + ) + + // 自定义背景 + backgroundUri.value?.let { uri -> + CustomBackgroundLayer(uri = uri, darkTheme = darkTheme) + } +} + +@Composable +private fun CustomBackgroundLayer(uri: Uri, darkTheme: Boolean) { + val painter = rememberAsyncImagePainter( + model = uri, + onError = { error -> + Log.e("ThemeSystem", "背景加载失败: ${error.result.throwable.message}") + ThemeConfig.customBackgroundUri = null + }, + onSuccess = { + Log.d("ThemeSystem", "背景加载成功") + ThemeConfig.backgroundImageLoaded = true + ThemeConfig.isThemeChanging = false + } + ) + + val transition = updateTransition( + targetState = ThemeConfig.backgroundImageLoaded, + label = "backgroundTransition" + ) + + val alpha by transition.animateFloat( + label = "backgroundAlpha", + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + } + ) { loaded -> if (loaded) 1f else 0f } + + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(-1f) + .alpha(alpha) + ) { + // 背景图片 + Box( + modifier = Modifier + .fillMaxSize() + .paint(painter = painter, contentScale = ContentScale.Crop) + .graphicsLayer { + this.alpha = (painter.state as? AsyncImagePainter.State.Success)?.let { 1f } ?: 0f + } + ) + + // 遮罩层 + BackgroundOverlay(darkTheme = darkTheme) + } +} + +@Composable +private fun BackgroundOverlay(darkTheme: Boolean) { + val dimFactor = CardConfig.cardDim + + // 主要遮罩层 + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (darkTheme) { + Color.Black.copy(alpha = 0.3f + dimFactor * 0.4f) + } else { + Color.White.copy(alpha = 0.05f + dimFactor * 0.3f) + } + ) + ) + + // 边缘渐变遮罩 + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf( + Color.Transparent, + if (darkTheme) { + Color.Black.copy(alpha = 0.2f + dimFactor * 0.2f) + } else { + Color.Black.copy(alpha = 0.05f + dimFactor * 0.1f) + } + ), + radius = 1000f + ) + ) + ) +} + +@Composable +private fun createColorScheme( + context: Context, + darkTheme: Boolean, + dynamicColor: Boolean +): ColorScheme { + return when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) createDynamicDarkColorScheme(context) + else createDynamicLightColorScheme(context) + } + darkTheme -> createDarkColorScheme() + else -> createLightColorScheme() + } +} + +@Composable +private fun SystemBarController(darkMode: Boolean) { + val context = LocalContext.current + val activity = context as ComponentActivity + + SideEffect { + activity.enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + Color.Transparent.toArgb(), + Color.Transparent.toArgb(), + ) { darkMode }, + navigationBarStyle = if (darkMode) { + SystemBarStyle.dark(Color.Transparent.toArgb()) + } else { + SystemBarStyle.light( + Color.Transparent.toArgb(), + Color.Transparent.toArgb() + ) + } + ) + } +} + +@RequiresApi(Build.VERSION_CODES.S) +@Composable +private fun createDynamicDarkColorScheme(context: Context): ColorScheme { + val scheme = dynamicDarkColorScheme(context) + return scheme.copy( + background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.background, + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.surface, + onBackground = scheme.onBackground, + onSurface = scheme.onSurface + ) +} + +@RequiresApi(Build.VERSION_CODES.S) +@Composable +private fun createDynamicLightColorScheme(context: Context): ColorScheme { + val scheme = dynamicLightColorScheme(context) + return scheme.copy( + background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.background, + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.surface, + onBackground = scheme.onBackground, + onSurface = scheme.onSurface + ) +} + +@Composable +private fun createDarkColorScheme() = darkColorScheme( + primary = ThemeConfig.currentTheme.primaryDark, + onPrimary = ThemeConfig.currentTheme.onPrimaryDark, + primaryContainer = ThemeConfig.currentTheme.primaryContainerDark, + onPrimaryContainer = ThemeConfig.currentTheme.onPrimaryContainerDark, + secondary = ThemeConfig.currentTheme.secondaryDark, + onSecondary = ThemeConfig.currentTheme.onSecondaryDark, + secondaryContainer = ThemeConfig.currentTheme.secondaryContainerDark, + onSecondaryContainer = ThemeConfig.currentTheme.onSecondaryContainerDark, + tertiary = ThemeConfig.currentTheme.tertiaryDark, + onTertiary = ThemeConfig.currentTheme.onTertiaryDark, + tertiaryContainer = ThemeConfig.currentTheme.tertiaryContainerDark, + onTertiaryContainer = ThemeConfig.currentTheme.onTertiaryContainerDark, + error = ThemeConfig.currentTheme.errorDark, + onError = ThemeConfig.currentTheme.onErrorDark, + errorContainer = ThemeConfig.currentTheme.errorContainerDark, + onErrorContainer = ThemeConfig.currentTheme.onErrorContainerDark, + background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.backgroundDark, + onBackground = ThemeConfig.currentTheme.onBackgroundDark, + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.surfaceDark, + onSurface = ThemeConfig.currentTheme.onSurfaceDark, + surfaceVariant = ThemeConfig.currentTheme.surfaceVariantDark, + onSurfaceVariant = ThemeConfig.currentTheme.onSurfaceVariantDark, + outline = ThemeConfig.currentTheme.outlineDark, + outlineVariant = ThemeConfig.currentTheme.outlineVariantDark, + scrim = ThemeConfig.currentTheme.scrimDark, + inverseSurface = ThemeConfig.currentTheme.inverseSurfaceDark, + inverseOnSurface = ThemeConfig.currentTheme.inverseOnSurfaceDark, + inversePrimary = ThemeConfig.currentTheme.inversePrimaryDark, + surfaceDim = ThemeConfig.currentTheme.surfaceDimDark, + surfaceBright = ThemeConfig.currentTheme.surfaceBrightDark, + surfaceContainerLowest = ThemeConfig.currentTheme.surfaceContainerLowestDark, + surfaceContainerLow = ThemeConfig.currentTheme.surfaceContainerLowDark, + surfaceContainer = ThemeConfig.currentTheme.surfaceContainerDark, + surfaceContainerHigh = ThemeConfig.currentTheme.surfaceContainerHighDark, + surfaceContainerHighest = ThemeConfig.currentTheme.surfaceContainerHighestDark, +) + +@Composable +private fun createLightColorScheme() = lightColorScheme( + primary = ThemeConfig.currentTheme.primaryLight, + onPrimary = ThemeConfig.currentTheme.onPrimaryLight, + primaryContainer = ThemeConfig.currentTheme.primaryContainerLight, + onPrimaryContainer = ThemeConfig.currentTheme.onPrimaryContainerLight, + secondary = ThemeConfig.currentTheme.secondaryLight, + onSecondary = ThemeConfig.currentTheme.onSecondaryLight, + secondaryContainer = ThemeConfig.currentTheme.secondaryContainerLight, + onSecondaryContainer = ThemeConfig.currentTheme.onSecondaryContainerLight, + tertiary = ThemeConfig.currentTheme.tertiaryLight, + onTertiary = ThemeConfig.currentTheme.onTertiaryLight, + tertiaryContainer = ThemeConfig.currentTheme.tertiaryContainerLight, + onTertiaryContainer = ThemeConfig.currentTheme.onTertiaryContainerLight, + error = ThemeConfig.currentTheme.errorLight, + onError = ThemeConfig.currentTheme.onErrorLight, + errorContainer = ThemeConfig.currentTheme.errorContainerLight, + onErrorContainer = ThemeConfig.currentTheme.onErrorContainerLight, + background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.backgroundLight, + onBackground = ThemeConfig.currentTheme.onBackgroundLight, + surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.surfaceLight, + onSurface = ThemeConfig.currentTheme.onSurfaceLight, + surfaceVariant = ThemeConfig.currentTheme.surfaceVariantLight, + onSurfaceVariant = ThemeConfig.currentTheme.onSurfaceVariantLight, + outline = ThemeConfig.currentTheme.outlineLight, + outlineVariant = ThemeConfig.currentTheme.outlineVariantLight, + scrim = ThemeConfig.currentTheme.scrimLight, + inverseSurface = ThemeConfig.currentTheme.inverseSurfaceLight, + inverseOnSurface = ThemeConfig.currentTheme.inverseOnSurfaceLight, + inversePrimary = ThemeConfig.currentTheme.inversePrimaryLight, + surfaceDim = ThemeConfig.currentTheme.surfaceDimLight, + surfaceBright = ThemeConfig.currentTheme.surfaceBrightLight, + surfaceContainerLowest = ThemeConfig.currentTheme.surfaceContainerLowestLight, + surfaceContainerLow = ThemeConfig.currentTheme.surfaceContainerLowLight, + surfaceContainer = ThemeConfig.currentTheme.surfaceContainerLight, + surfaceContainerHigh = ThemeConfig.currentTheme.surfaceContainerHighLight, + surfaceContainerHighest = ThemeConfig.currentTheme.surfaceContainerHighestLight, +) + +// 向后兼容 +@OptIn(DelicateCoroutinesApi::class) +fun Context.saveAndApplyCustomBackground(uri: Uri, transformation: BackgroundTransformation? = null) { + kotlinx.coroutines.GlobalScope.launch { + BackgroundManager.saveAndApplyCustomBackground(this@saveAndApplyCustomBackground, uri, transformation) + } +} + +fun Context.saveCustomBackground(uri: Uri?) { + if (uri != null) { + saveAndApplyCustomBackground(uri) + } else { + BackgroundManager.clearCustomBackground(this) + } +} + +fun Context.saveThemeMode(forceDark: Boolean?) { + ThemeManager.saveThemeMode(this, forceDark) +} + + +fun Context.saveThemeColors(themeName: String) { + ThemeManager.saveThemeColors(this, themeName) +} + + +fun Context.saveDynamicColorState(enabled: Boolean) { + ThemeManager.saveDynamicColorState(this, enabled) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Type.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Type.kt new file mode 100644 index 0000000..beefa2e --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/Type.kt @@ -0,0 +1,108 @@ +package com.sukisu.ultra.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + // 大标题 + displayLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + + // 标题 + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + + // 标题栏 + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + + // 主体文字 + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + + // 标签 + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/theme/component/ImageEditorDialog.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/component/ImageEditorDialog.kt new file mode 100644 index 0000000..803d1f0 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/component/ImageEditorDialog.kt @@ -0,0 +1,411 @@ +package com.sukisu.ultra.ui.theme.component + +import android.net.Uri +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.theme.util.BackgroundTransformation +import com.sukisu.ultra.ui.theme.util.saveTransformedBackground +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.max + +@Composable +fun ImageEditorDialog( + imageUri: Uri, + onDismiss: () -> Unit, + onConfirm: (Uri) -> Unit +) { + // 图像变换状态 + val transformState = remember { ImageTransformState() } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // 尺寸状态 + var imageSize by remember { mutableStateOf(Size.Zero) } + var screenSize by remember { mutableStateOf(Size.Zero) } + + // 动画状态 + val animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + + val animatedScale by animateFloatAsState( + targetValue = transformState.scale, + animationSpec = animationSpec, + label = "ScaleAnimation" + ) + + val animatedOffsetX by animateFloatAsState( + targetValue = transformState.offsetX, + animationSpec = animationSpec, + label = "OffsetXAnimation" + ) + + val animatedOffsetY by animateFloatAsState( + targetValue = transformState.offsetY, + animationSpec = animationSpec, + label = "OffsetYAnimation" + ) + + // 工具函数 + val scaleToFullScreen = remember { + { + if (imageSize.height > 0 && screenSize.height > 0) { + val newScale = screenSize.height / imageSize.height + transformState.updateTransform(newScale, 0f, 0f) + } + } + } + + val saveImage: () -> Unit = remember { + { + scope.launch { + try { + val transformation = BackgroundTransformation( + transformState.scale, + transformState.offsetX, + transformState.offsetY + ) + val savedUri = context.saveTransformedBackground(imageUri, transformation) + savedUri?.let { onConfirm(it) } + } catch (_: Exception) { + } + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf( + Color.Black.copy(alpha = 0.9f), + Color.Black.copy(alpha = 0.95f) + ), + radius = 800f + ) + ) + .onSizeChanged { size -> + screenSize = Size(size.width.toFloat(), size.height.toFloat()) + } + ) { + // 图像显示区域 + ImageDisplayArea( + imageUri = imageUri, + animatedScale = animatedScale, + animatedOffsetX = animatedOffsetX, + animatedOffsetY = animatedOffsetY, + transformState = transformState, + onImageSizeChanged = { imageSize = it }, + modifier = Modifier.fillMaxSize() + ) + + // 顶部工具栏 + TopToolbar( + onDismiss = onDismiss, + onFullscreen = scaleToFullScreen, + onConfirm = saveImage, + modifier = Modifier.align(Alignment.TopCenter) + ) + + // 底部提示信息 + BottomHintCard( + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} + +/** + * 图像变换状态管理类 + */ +private class ImageTransformState { + var scale by mutableFloatStateOf(1f) + var offsetX by mutableFloatStateOf(0f) + var offsetY by mutableFloatStateOf(0f) + + private var lastScale = 1f + private var lastOffsetX = 0f + private var lastOffsetY = 0f + + fun updateTransform(newScale: Float, newOffsetX: Float, newOffsetY: Float) { + val scaleDiff = abs(newScale - lastScale) + val offsetXDiff = abs(newOffsetX - lastOffsetX) + val offsetYDiff = abs(newOffsetY - lastOffsetY) + + if (scaleDiff > 0.01f || offsetXDiff > 1f || offsetYDiff > 1f) { + scale = newScale + offsetX = newOffsetX + offsetY = newOffsetY + lastScale = newScale + lastOffsetX = newOffsetX + lastOffsetY = newOffsetY + } + } + + fun resetToLast() { + scale = lastScale + offsetX = lastOffsetX + offsetY = lastOffsetY + } +} + +/** + * 图像显示区域组件 + */ +@Composable +private fun ImageDisplayArea( + imageUri: Uri, + animatedScale: Float, + animatedOffsetX: Float, + animatedOffsetY: Float, + transformState: ImageTransformState, + onImageSizeChanged: (Size) -> Unit, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUri) + .crossfade(true) + .build(), + contentDescription = stringResource(R.string.settings_custom_background), + contentScale = ContentScale.Fit, + modifier = modifier + .graphicsLayer( + scaleX = animatedScale, + scaleY = animatedScale, + translationX = animatedOffsetX, + translationY = animatedOffsetY + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scope.launch { + try { + val newScale = (transformState.scale * zoom).coerceIn(0.5f, 3f) + val maxOffsetX = max(0f, size.width * (newScale - 1) / 2) + val maxOffsetY = max(0f, size.height * (newScale - 1) / 2) + + val newOffsetX = if (maxOffsetX > 0) { + (transformState.offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX) + } else 0f + + val newOffsetY = if (maxOffsetY > 0) { + (transformState.offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY) + } else 0f + + transformState.updateTransform(newScale, newOffsetX, newOffsetY) + } catch (_: Exception) { + transformState.resetToLast() + } + } + } + } + .onSizeChanged { size -> + onImageSizeChanged(Size(size.width.toFloat(), size.height.toFloat())) + } + ) +} + +/** + * 顶部工具栏组件 + */ +@Composable +private fun TopToolbar( + onDismiss: () -> Unit, + onFullscreen: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // 关闭按钮 + ActionButton( + onClick = onDismiss, + icon = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel), + backgroundColor = MaterialTheme.colorScheme.error.copy(alpha = 0.9f) + ) + + // 全屏按钮 + ActionButton( + onClick = onFullscreen, + icon = Icons.Default.Fullscreen, + contentDescription = stringResource(R.string.reprovision), + backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) + ) + + // 确认按钮 + ActionButton( + onClick = onConfirm, + icon = Icons.Default.Check, + contentDescription = stringResource(R.string.confirm), + backgroundColor = Color(0xFF4CAF50).copy(alpha = 0.9f) + ) + } +} + +/** + * 操作按钮组件 + */ +@Composable +private fun ActionButton( + onClick: () -> Unit, + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + var isPressed by remember { mutableStateOf(false) } + + val buttonScale by animateFloatAsState( + targetValue = if (isPressed) 0.85f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "ButtonScale" + ) + + val buttonAlpha by animateFloatAsState( + targetValue = if (isPressed) 0.8f else 1f, + animationSpec = tween(100), + label = "ButtonAlpha" + ) + + Surface( + onClick = { + isPressed = true + onClick() + }, + modifier = modifier + .size(64.dp) + .graphicsLayer( + scaleX = buttonScale, + scaleY = buttonScale, + alpha = buttonAlpha + ), + shape = CircleShape, + color = backgroundColor, + shadowElevation = 8.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + + LaunchedEffect(isPressed) { + if (isPressed) { + kotlinx.coroutines.delay(150) + isPressed = false + } + } +} + +/** + * 底部提示卡片组件 + */ +@Composable +private fun BottomHintCard( + modifier: Modifier = Modifier +) { + var isVisible by remember { mutableStateOf(true) } + + val cardAlpha by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween( + durationMillis = 500, + easing = EaseInOutCubic + ), + label = "HintAlpha" + ) + + val cardTranslationY by animateFloatAsState( + targetValue = if (isVisible) 0f else 100f, + animationSpec = tween( + durationMillis = 500, + easing = EaseInOutCubic + ), + label = "HintTranslation" + ) + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(4000) + isVisible = false + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(24.dp) + .alpha(cardAlpha) + .graphicsLayer { + translationY = cardTranslationY + }, + colors = CardDefaults.cardColors( + containerColor = Color.Black.copy(alpha = 0.85f) + ), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) + ) { + Text( + text = stringResource(id = R.string.image_editor_hint), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(20.dp) + .fillMaxWidth() + ) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/theme/util/BackgroundUtils.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/util/BackgroundUtils.kt new file mode 100644 index 0000000..daf089b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/theme/util/BackgroundUtils.kt @@ -0,0 +1,110 @@ +package com.sukisu.ultra.ui.theme.util + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.net.Uri +import android.util.Log +import androidx.core.graphics.createBitmap +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +data class BackgroundTransformation( + val scale: Float = 1f, + val offsetX: Float = 0f, + val offsetY: Float = 0f +) + +fun Context.getImageBitmap(uri: Uri): Bitmap? { + return try { + val contentResolver: ContentResolver = contentResolver + val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream.close() + bitmap + } catch (e: Exception) { + Log.e("BackgroundUtils", "Failed to get image bitmap: ${e.message}") + null + } +} + +fun Context.applyTransformationToBitmap(bitmap: Bitmap, transformation: BackgroundTransformation): Bitmap { + val width = bitmap.width + val height = bitmap.height + + // 创建与屏幕比例相同的目标位图 + val displayMetrics = resources.displayMetrics + val screenWidth = displayMetrics.widthPixels + val screenHeight = displayMetrics.heightPixels + val screenRatio = screenHeight.toFloat() / screenWidth.toFloat() + + // 计算目标宽高 + val targetWidth: Int + val targetHeight: Int + if (width.toFloat() / height.toFloat() > screenRatio) { + targetHeight = height + targetWidth = (height / screenRatio).toInt() + } else { + targetWidth = width + targetHeight = (width * screenRatio).toInt() + } + + // 创建与目标相同大小的位图 + val scaledBitmap = createBitmap(targetWidth, targetHeight) + val canvas = Canvas(scaledBitmap) + + val matrix = Matrix() + + // 确保缩放值有效 + val safeScale = maxOf(0.1f, transformation.scale) + matrix.postScale(safeScale, safeScale) + + // 计算偏移量,确保不会出现负最大值的问题 + val widthDiff = (bitmap.width * safeScale - targetWidth) + val heightDiff = (bitmap.height * safeScale - targetHeight) + + // 安全计算偏移量边界 + val maxOffsetX = maxOf(0f, widthDiff / 2) + val maxOffsetY = maxOf(0f, heightDiff / 2) + + // 限制偏移范围 + val safeOffsetX = if (maxOffsetX > 0) + transformation.offsetX.coerceIn(-maxOffsetX, maxOffsetX) else 0f + val safeOffsetY = if (maxOffsetY > 0) + transformation.offsetY.coerceIn(-maxOffsetY, maxOffsetY) else 0f + + // 应用偏移量到矩阵 + val translationX = -widthDiff / 2 + safeOffsetX + val translationY = -heightDiff / 2 + safeOffsetY + + matrix.postTranslate(translationX, translationY) + + // 将原始位图绘制到新位图上 + canvas.drawBitmap(bitmap, matrix, null) + + return scaledBitmap +} + +fun Context.saveTransformedBackground(uri: Uri, transformation: BackgroundTransformation): Uri? { + try { + val bitmap = getImageBitmap(uri) ?: return null + val transformedBitmap = applyTransformationToBitmap(bitmap, transformation) + + val fileName = "custom_background_transformed.jpg" + val file = File(filesDir, fileName) + val outputStream = FileOutputStream(file) + + transformedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + outputStream.flush() + outputStream.close() + + return Uri.fromFile(file) + } catch (e: Exception) { + Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}", e) + return null + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/CompositionProvider.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/CompositionProvider.kt new file mode 100644 index 0000000..1ba64d7 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/CompositionProvider.kt @@ -0,0 +1,8 @@ +package com.sukisu.ultra.ui.util + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.compositionLocalOf + +val LocalSnackbarHost = compositionLocalOf { + error("CompositionLocal LocalSnackbarController not present") +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt new file mode 100644 index 0000000..035137f --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt @@ -0,0 +1,319 @@ +package com.sukisu.ultra.ui.util + +import android.annotation.SuppressLint +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import com.sukisu.ultra.ui.util.module.LatestVersionInfo +import java.io.File +import java.util.concurrent.TimeUnit + +private const val TAG = "DownloadUtil" +private val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})" +private const val MAX_RETRY_COUNT = 3 +private const val RETRY_DELAY_MS = 3000L + +/** + * @author weishu + * @date 2023/6/22. + */ +@SuppressLint("Range") +fun download( + context: Context, + url: String, + fileName: String, + description: String, + onDownloaded: (Uri) -> Unit = {}, + onDownloading: () -> Unit = {}, + onError: (String) -> Unit = {} +) { + Log.d(TAG, "Start Download: $url") + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + + val query = DownloadManager.Query() + query.setFilterByStatus(DownloadManager.STATUS_RUNNING or DownloadManager.STATUS_PAUSED or DownloadManager.STATUS_PENDING) + downloadManager.query(query).use { cursor -> + while (cursor.moveToNext()) { + val uri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI)) + val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + val columnTitle = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)) + if (url == uri || fileName == columnTitle) { + if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) { + onDownloading() + return + } else if (status == DownloadManager.STATUS_SUCCESSFUL) { + onDownloaded(localUri.toUri()) + return + } + } + } + } + val downloadFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + fileName + ) + if (downloadFile.exists()) { + downloadFile.delete() + } + + val request = DownloadManager.Request(url.toUri()) + .setDestinationInExternalPublicDir( + Environment.DIRECTORY_DOWNLOADS, + fileName + ) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setMimeType("application/zip") + .setTitle(fileName) + .setDescription(description) + .addRequestHeader("User-Agent", CUSTOM_USER_AGENT) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + .setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE) + + try { + val downloadId = downloadManager.enqueue(request) + Log.d(TAG, "Successful launch of the download,ID: $downloadId") + monitorDownload(context, downloadManager, downloadId, url, fileName, description, onDownloaded, onDownloading, onError) + } catch (e: Exception) { + Log.e(TAG, "Download startup failure", e) + onError("Download startup failure: ${e.message}") + } +} + +private fun monitorDownload( + context: Context, + downloadManager: DownloadManager, + downloadId: Long, + url: String, + fileName: String, + description: String, + onDownloaded: (Uri) -> Unit, + onDownloading: () -> Unit, + onError: (String) -> Unit, + retryCount: Int = 0 +) { + val handler = Handler(Looper.getMainLooper()) + val query = DownloadManager.Query().setFilterById(downloadId) + + var lastProgress = -1 + var stuckCounter = 0 + + val runnable = object : Runnable { + override fun run() { + downloadManager.query(query).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + @SuppressLint("Range") + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + + when (status) { + DownloadManager.STATUS_SUCCESSFUL -> { + @SuppressLint("Range") + val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + Log.d(TAG, "Download Successfully: $localUri") + onDownloaded(localUri.toUri()) + return + } + DownloadManager.STATUS_FAILED -> { + @SuppressLint("Range") + val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) + Log.d(TAG, "Download failed with reason code: $reason") + + if (retryCount < MAX_RETRY_COUNT) { + Log.d(TAG, "Attempts to re download, number of retries: ${retryCount + 1}") + handler.postDelayed({ + downloadManager.remove(downloadId) + download(context, url, fileName, description, onDownloaded, onDownloading, onError) + }, RETRY_DELAY_MS) + } else { + onError("Download failed, please check network connection or storage space") + } + return + } + DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> { + @SuppressLint("Range") + val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + @SuppressLint("Range") + val downloadedBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + + if (totalBytes > 0) { + val progress = (downloadedBytes * 100 / totalBytes).toInt() + if (progress == lastProgress) { + stuckCounter++ + if (stuckCounter > 30) { + if (retryCount < MAX_RETRY_COUNT) { + Log.d(TAG, "Download stalled and restarted") + downloadManager.remove(downloadId) + download(context, url, fileName, description, onDownloaded, onDownloading, onError) + return + } + } + } else { + lastProgress = progress + stuckCounter = 0 + Log.d(TAG, "Download progress: $progress% ($downloadedBytes/$totalBytes)") + } + } + } + } + } + } + handler.postDelayed(this, 1000) + } + } + handler.post(runnable) + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1 + if (id == downloadId) { + handler.removeCallbacks(runnable) + + val query = DownloadManager.Query().setFilterById(downloadId) + downloadManager.query(query).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + @SuppressLint("Range") + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + + if (status == DownloadManager.STATUS_SUCCESSFUL) { + @SuppressLint("Range") + val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + onDownloaded(localUri.toUri()) + } else { + if (retryCount < MAX_RETRY_COUNT) { + download(context!!, url, fileName, description, onDownloaded, onDownloading, onError) + } else { + onError("Download failed, please try again later") + } + } + } + } + + context?.unregisterReceiver(this) + } + } + } + + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + ContextCompat.RECEIVER_EXPORTED + ) +} + +fun checkNewVersion(): LatestVersionInfo { + val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest" + val defaultValue = LatestVersionInfo() + return runCatching { + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", CUSTOM_USER_AGENT) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.d("CheckUpdate", "Network request failed: ${response.message}") + return defaultValue + } + val body = response.body?.string() + if (body == null) { + Log.d("CheckUpdate", "Return data is null") + return defaultValue + } + Log.d("CheckUpdate", "Return data: $body") + val json = org.json.JSONObject(body) + + // 直接从 tag_name 提取版本号(如 v1.1) + val tagName = json.optString("tag_name", "") + val versionName = tagName.removePrefix("v") // 移除前缀 "v" + + // 从 body 字段获取更新日志(保留换行符) + val changelog = json.optString("body") + .replace("\\r\\n", "\n") // 转换换行符 + + val assets = json.getJSONArray("assets") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.getString("name") + if (!name.endsWith(".apk")) continue + + val regex = Regex("SukiSU.*_(\\d+)-release") + val matchResult = regex.find(name) + if (matchResult == null) { + Log.d("CheckUpdate", "No matches found: $name, skip over") + continue + } + val versionCode = matchResult.groupValues[1].toInt() + + val downloadUrl = asset.getString("browser_download_url") + return LatestVersionInfo( + versionCode, + downloadUrl, + changelog, + versionName + ) + } + Log.d("CheckUpdate", "No valid APK resource found, return default value") + defaultValue + } + }.getOrDefault(defaultValue) +} + +@Composable +fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) { + DisposableEffect(context) { + val receiver = object : BroadcastReceiver() { + @SuppressLint("Range") + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) { + val id = intent.getLongExtra( + DownloadManager.EXTRA_DOWNLOAD_ID, -1 + ) + val query = DownloadManager.Query().setFilterById(id) + val downloadManager = + context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val cursor = downloadManager.query(query) + if (cursor.moveToFirst()) { + val status = cursor.getInt( + cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + ) + if (status == DownloadManager.STATUS_SUCCESSFUL) { + val uri = cursor.getString( + cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) + ) + onDownloaded(uri.toUri()) + } + } + } + } + } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + ContextCompat.RECEIVER_EXPORTED + ) + onDispose { + context.unregisterReceiver(receiver) + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/HanziToPinyin.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/HanziToPinyin.kt new file mode 100644 index 0000000..edfbdf5 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/HanziToPinyin.kt @@ -0,0 +1,522 @@ +package com.sukisu.ultra.ui.util + +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.text.TextUtils +import android.util.Log +import java.text.Collator +import java.util.Locale + +class HanziToPinyin private constructor(val hasChinaCollator: Boolean) { + + class Token( + var type: Int = 0, + var source: String = "", + var target: String = "" + ) { + companion object { + const val LATIN = 1 + const val PINYIN = 2 + const val UNKNOWN = 3 + } + } + + private fun getToken(character: Char): Token { + val token = Token() + val letter = character.toString() + token.source = letter + var offset = -1 + var cmp: Int + + if (character < 256.toChar()) { + token.type = Token.LATIN + token.target = letter + return token + } else { + cmp = COLLATOR.compare(letter, FIRST_PINYIN_UNIHAN) + if (cmp < 0) { + token.type = Token.UNKNOWN + token.target = letter + return token + } else if (cmp == 0) { + token.type = Token.PINYIN + offset = 0 + } else { + cmp = COLLATOR.compare(letter, LAST_PINYIN_UNIHAN) + if (cmp > 0) { + token.type = Token.UNKNOWN + token.target = letter + return token + } else if (cmp == 0) { + token.type = Token.PINYIN + offset = UNIHANS.size - 1 + } + } + } + + token.type = Token.PINYIN + if (offset < 0) { + var begin = 0 + var end = UNIHANS.size - 1 + while (begin <= end) { + offset = (begin + end) / 2 + val unihan = UNIHANS[offset].toString() + cmp = COLLATOR.compare(letter, unihan) + when { + cmp == 0 -> break + cmp > 0 -> begin = offset + 1 + else -> end = offset - 1 + } + } + } + if (cmp < 0) { + offset-- + } + + val pinyin = StringBuilder() + for (j in PINYINS[offset].indices) { + if (PINYINS[offset][j] == 0.toByte()) break + pinyin.append(PINYINS[offset][j].toInt().toChar()) + } + token.target = pinyin.toString() + if (TextUtils.isEmpty(token.target)) { + token.type = Token.UNKNOWN + token.target = token.source + } + return token + } + + fun get(input: String?): ArrayList { + val tokens = ArrayList() + if (!hasChinaCollator || TextUtils.isEmpty(input)) { + return tokens + } + + val inputLength = input!!.length + val sb = StringBuilder() + var tokenType = Token.LATIN + + for (i in 0 until inputLength) { + val character = input[i] + when { + character == ' ' -> { + if (sb.isNotEmpty()) { + addToken(sb, tokens, tokenType) + } + } + character < 256.toChar() -> { + if (tokenType != Token.LATIN && sb.isNotEmpty()) { + addToken(sb, tokens, tokenType) + } + tokenType = Token.LATIN + sb.append(character) + } + else -> { + val t = getToken(character) + if (t.type == Token.PINYIN) { + if (sb.isNotEmpty()) { + addToken(sb, tokens, tokenType) + } + tokens.add(t) + tokenType = Token.PINYIN + } else { + if (tokenType != t.type && sb.isNotEmpty()) { + addToken(sb, tokens, tokenType) + } + tokenType = t.type + sb.append(character) + } + } + } + } + if (sb.isNotEmpty()) { + addToken(sb, tokens, tokenType) + } + return tokens + } + + private fun addToken(sb: StringBuilder, tokens: ArrayList, tokenType: Int) { + val str = sb.toString() + tokens.add(Token(tokenType, str, str)) + sb.setLength(0) + } + + fun toPinyinString(string: String?): String? { + if (string == null) { + return null + } + val sb = StringBuilder() + val tokens = get(string) + for (token in tokens) { + sb.append(token.target) + } + return sb.toString().lowercase() + } + + companion object { + private const val TAG = "HanziToPinyin" + private const val DEBUG = false + + val UNIHANS = charArrayOf( + '阿', '哎', '安', '肮', '凹', '八', + '挀', '扳', '邦', '勹', '陂', '奔', + '伻', '屄', '边', '灬', '憋', '汃', + '冫', '癶', '峬', '嚓', '偲', '参', + '仓', '撡', '冊', '嵾', '曽', '曾', + '層', '叉', '芆', '辿', '伥', '抄', + '车', '抻', '沈', '沉', '阷', '吃', + '充', '抽', '出', '欻', '揣', '巛', + '刅', '吹', '旾', '逴', '呲', '匆', + '凑', '粗', '汆', '崔', '邨', '搓', + '咑', '呆', '丹', '当', '刀', '嘚', + '扥', '灯', '氐', '嗲', '甸', '刁', + '爹', '丁', '丟', '东', '吺', '厾', + '耑', '襨', '吨', '多', '妸', '诶', + '奀', '鞥', '儿', '发', '帆', '匚', + '飞', '分', '丰', '覅', '仏', '紑', + '伕', '旮', '侅', '甘', '冈', '皋', + '戈', '给', '根', '刯', '工', '勾', + '估', '瓜', '乖', '关', '光', '归', + '丨', '呙', '哈', '咍', '佄', '夯', + '茠', '诃', '黒', '拫', '亨', '噷', + '叿', '齁', '乯', '花', '怀', '犿', + '巟', '灰', '昏', '吙', '丌', '加', + '戋', '江', '艽', '阶', '巾', '坕', + '冂', '丩', '凥', '姢', '噘', '军', + '咔', '开', '刊', '忼', '尻', '匼', + '肎', '劥', '空', '抠', '扝', '夸', + '蒯', '宽', '匡', '亏', '坤', '扩', + '垃', '来', '兰', '啷', '捞', '肋', + '勒', '崚', '刕', '俩', '奁', '良', + '撩', '列', '拎', '刢', '溜', '囖', + '龙', '瞜', '噜', '娈', '畧', '抡', + '罗', '呣', '妈', '埋', '嫚', '牤', + '猫', '么', '呅', '门', '甿', '咪', + '宀', '喵', '乜', '民', '名', '谬', + '摸', '哞', '毪', '嗯', '拏', '腉', + '囡', '囔', '孬', '疒', '娞', '恁', + '能', '妮', '拈', '嬢', '鸟', '捏', + '囜', '宁', '妞', '农', '羺', '奴', + '奻', '疟', '黁', '郍', '喔', '讴', + '妑', '拍', '眅', '乓', '抛', '呸', + '喷', '匉', '丕', '囨', '剽', '氕', + '姘', '乒', '钋', '剖', '仆', '七', + '掐', '千', '呛', '悄', '癿', '亲', + '狅', '芎', '丘', '区', '峑', '缺', + '夋', '呥', '穣', '娆', '惹', '人', + '扔', '日', '茸', '厹', '邚', '挼', + '堧', '婑', '瞤', '捼', '仨', '毢', + '三', '桒', '掻', '閪', '森', '僧', + '杀', '筛', '山', '伤', '弰', '奢', + '申', '莘', '敒', '升', '尸', '収', + '书', '刷', '衰', '闩', '双', '谁', + '吮', '说', '厶', '忪', '捜', '苏', + '狻', '夊', '孙', '唆', '他', '囼', + '坍', '汤', '夲', '忑', '熥', '剔', + '天', '旫', '帖', '厅', '囲', '偷', + '凸', '湍', '推', '吞', '乇', '穵', + '歪', '弯', '尣', '危', '昷', '翁', + '挝', '乌', '夕', '虲', '仚', '乡', + '灱', '些', '心', '星', '凶', '休', + '吁', '吅', '削', '坃', '丫', '恹', + '央', '幺', '倻', '一', '囙', '应', + '哟', '佣', '优', '扜', '囦', '曰', + '晕', '筠', '筼', '帀', '災', '兂', + '匨', '傮', '则', '贼', '怎', '増', + '扎', '捚', '沾', '张', '长', '長', + '佋', '蜇', '贞', '争', '之', '峙', + '庢', '中', '州', '朱', '抓', '拽', + '专', '妆', '隹', '宒', '卓', '乲', + '宗', '邹', '租', '钻', '厜', '尊', + '昨', '兙', '鿃', '鿄' + ) + + val PINYINS = arrayOf( + byteArrayOf(65, 0, 0, 0, 0, 0), byteArrayOf(65, 73, 0, 0, 0, 0), + byteArrayOf(65, 78, 0, 0, 0, 0), byteArrayOf(65, 78, 71, 0, 0, 0), + byteArrayOf(65, 79, 0, 0, 0, 0), byteArrayOf(66, 65, 0, 0, 0, 0), + byteArrayOf(66, 65, 73, 0, 0, 0), byteArrayOf(66, 65, 78, 0, 0, 0), + byteArrayOf(66, 65, 78, 71, 0, 0), byteArrayOf(66, 65, 79, 0, 0, 0), + byteArrayOf(66, 69, 73, 0, 0, 0), byteArrayOf(66, 69, 78, 0, 0, 0), + byteArrayOf(66, 69, 78, 71, 0, 0), byteArrayOf(66, 73, 0, 0, 0, 0), + byteArrayOf(66, 73, 65, 78, 0, 0), byteArrayOf(66, 73, 65, 79, 0, 0), + byteArrayOf(66, 73, 69, 0, 0, 0), byteArrayOf(66, 73, 78, 0, 0, 0), + byteArrayOf(66, 73, 78, 71, 0, 0), byteArrayOf(66, 79, 0, 0, 0, 0), + byteArrayOf(66, 85, 0, 0, 0, 0), byteArrayOf(67, 65, 0, 0, 0, 0), + byteArrayOf(67, 65, 73, 0, 0, 0), byteArrayOf(67, 65, 78, 0, 0, 0), + byteArrayOf(67, 65, 78, 71, 0, 0), byteArrayOf(67, 65, 79, 0, 0, 0), + byteArrayOf(67, 69, 0, 0, 0, 0), byteArrayOf(67, 69, 78, 0, 0, 0), + byteArrayOf(67, 69, 78, 71, 0, 0), byteArrayOf(90, 69, 78, 71, 0, 0), + byteArrayOf(67, 69, 78, 71, 0, 0), byteArrayOf(67, 72, 65, 0, 0, 0), + byteArrayOf(67, 72, 65, 73, 0, 0), byteArrayOf(67, 72, 65, 78, 0, 0), + byteArrayOf(67, 72, 65, 78, 71, 0), byteArrayOf(67, 72, 65, 79, 0, 0), + byteArrayOf(67, 72, 69, 0, 0, 0), byteArrayOf(67, 72, 69, 78, 0, 0), + byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(67, 72, 69, 78, 0, 0), + byteArrayOf(67, 72, 69, 78, 71, 0), byteArrayOf(67, 72, 73, 0, 0, 0), + byteArrayOf(67, 72, 79, 78, 71, 0), byteArrayOf(67, 72, 79, 85, 0, 0), + byteArrayOf(67, 72, 85, 0, 0, 0), byteArrayOf(67, 72, 85, 65, 0, 0), + byteArrayOf(67, 72, 85, 65, 73, 0), byteArrayOf(67, 72, 85, 65, 78, 0), + byteArrayOf(67, 72, 85, 65, 78, 71), byteArrayOf(67, 72, 85, 73, 0, 0), + byteArrayOf(67, 72, 85, 78, 0, 0), byteArrayOf(67, 72, 85, 79, 0, 0), + byteArrayOf(67, 73, 0, 0, 0, 0), byteArrayOf(67, 79, 78, 71, 0, 0), + byteArrayOf(67, 79, 85, 0, 0, 0), byteArrayOf(67, 85, 0, 0, 0, 0), + byteArrayOf(67, 85, 65, 78, 0, 0), byteArrayOf(67, 85, 73, 0, 0, 0), + byteArrayOf(67, 85, 78, 0, 0, 0), byteArrayOf(67, 85, 79, 0, 0, 0), + byteArrayOf(68, 65, 0, 0, 0, 0), byteArrayOf(68, 65, 73, 0, 0, 0), + byteArrayOf(68, 65, 78, 0, 0, 0), byteArrayOf(68, 65, 78, 71, 0, 0), + byteArrayOf(68, 65, 79, 0, 0, 0), byteArrayOf(68, 69, 0, 0, 0, 0), + byteArrayOf(68, 69, 78, 0, 0, 0), byteArrayOf(68, 69, 78, 71, 0, 0), + byteArrayOf(68, 73, 0, 0, 0, 0), byteArrayOf(68, 73, 65, 0, 0, 0), + byteArrayOf(68, 73, 65, 78, 0, 0), byteArrayOf(68, 73, 65, 79, 0, 0), + byteArrayOf(68, 73, 69, 0, 0, 0), byteArrayOf(68, 73, 78, 71, 0, 0), + byteArrayOf(68, 73, 85, 0, 0, 0), byteArrayOf(68, 79, 78, 71, 0, 0), + byteArrayOf(68, 79, 85, 0, 0, 0), byteArrayOf(68, 85, 0, 0, 0, 0), + byteArrayOf(68, 85, 65, 78, 0, 0), byteArrayOf(68, 85, 73, 0, 0, 0), + byteArrayOf(68, 85, 78, 0, 0, 0), byteArrayOf(68, 85, 79, 0, 0, 0), + byteArrayOf(69, 0, 0, 0, 0, 0), byteArrayOf(69, 73, 0, 0, 0, 0), + byteArrayOf(69, 78, 0, 0, 0, 0), byteArrayOf(69, 78, 71, 0, 0, 0), + byteArrayOf(69, 82, 0, 0, 0, 0), byteArrayOf(70, 65, 0, 0, 0, 0), + byteArrayOf(70, 65, 78, 0, 0, 0), byteArrayOf(70, 65, 78, 71, 0, 0), + byteArrayOf(70, 69, 73, 0, 0, 0), byteArrayOf(70, 69, 78, 0, 0, 0), + byteArrayOf(70, 69, 78, 71, 0, 0), byteArrayOf(70, 73, 65, 79, 0, 0), + byteArrayOf(70, 79, 0, 0, 0, 0), byteArrayOf(70, 79, 85, 0, 0, 0), + byteArrayOf(70, 85, 0, 0, 0, 0), byteArrayOf(71, 65, 0, 0, 0, 0), + byteArrayOf(71, 65, 73, 0, 0, 0), byteArrayOf(71, 65, 78, 0, 0, 0), + byteArrayOf(71, 65, 78, 71, 0, 0), byteArrayOf(71, 65, 79, 0, 0, 0), + byteArrayOf(71, 69, 0, 0, 0, 0), byteArrayOf(71, 69, 73, 0, 0, 0), + byteArrayOf(71, 69, 78, 0, 0, 0), byteArrayOf(71, 69, 78, 71, 0, 0), + byteArrayOf(71, 79, 78, 71, 0, 0), byteArrayOf(71, 79, 85, 0, 0, 0), + byteArrayOf(71, 85, 0, 0, 0, 0), byteArrayOf(71, 85, 65, 0, 0, 0), + byteArrayOf(71, 85, 65, 73, 0, 0), byteArrayOf(71, 85, 65, 78, 0, 0), + byteArrayOf(71, 85, 65, 78, 71, 0), byteArrayOf(71, 85, 73, 0, 0, 0), + byteArrayOf(71, 85, 78, 0, 0, 0), byteArrayOf(71, 85, 79, 0, 0, 0), + byteArrayOf(72, 65, 0, 0, 0, 0), byteArrayOf(72, 65, 73, 0, 0, 0), + byteArrayOf(72, 65, 78, 0, 0, 0), byteArrayOf(72, 65, 78, 71, 0, 0), + byteArrayOf(72, 65, 79, 0, 0, 0), byteArrayOf(72, 69, 0, 0, 0, 0), + byteArrayOf(72, 69, 73, 0, 0, 0), byteArrayOf(72, 69, 78, 0, 0, 0), + byteArrayOf(72, 69, 78, 71, 0, 0), byteArrayOf(72, 77, 0, 0, 0, 0), + byteArrayOf(72, 79, 78, 71, 0, 0), byteArrayOf(72, 79, 85, 0, 0, 0), + byteArrayOf(72, 85, 0, 0, 0, 0), byteArrayOf(72, 85, 65, 0, 0, 0), + byteArrayOf(72, 85, 65, 73, 0, 0), byteArrayOf(72, 85, 65, 78, 0, 0), + byteArrayOf(72, 85, 65, 78, 71, 0), byteArrayOf(72, 85, 73, 0, 0, 0), + byteArrayOf(72, 85, 78, 0, 0, 0), byteArrayOf(72, 85, 79, 0, 0, 0), + byteArrayOf(74, 73, 0, 0, 0, 0), byteArrayOf(74, 73, 65, 0, 0, 0), + byteArrayOf(74, 73, 65, 78, 0, 0), byteArrayOf(74, 73, 65, 78, 71, 0), + byteArrayOf(74, 73, 65, 79, 0, 0), byteArrayOf(74, 73, 69, 0, 0, 0), + byteArrayOf(74, 73, 78, 0, 0, 0), byteArrayOf(74, 73, 78, 71, 0, 0), + byteArrayOf(74, 73, 79, 78, 71, 0), byteArrayOf(74, 73, 85, 0, 0, 0), + byteArrayOf(74, 85, 0, 0, 0, 0), byteArrayOf(74, 85, 65, 78, 0, 0), + byteArrayOf(74, 85, 69, 0, 0, 0), byteArrayOf(74, 85, 78, 0, 0, 0), + byteArrayOf(75, 65, 0, 0, 0, 0), byteArrayOf(75, 65, 73, 0, 0, 0), + byteArrayOf(75, 65, 78, 0, 0, 0), byteArrayOf(75, 65, 78, 71, 0, 0), + byteArrayOf(75, 65, 79, 0, 0, 0), byteArrayOf(75, 69, 0, 0, 0, 0), + byteArrayOf(75, 69, 78, 0, 0, 0), byteArrayOf(75, 69, 78, 71, 0, 0), + byteArrayOf(75, 79, 78, 71, 0, 0), byteArrayOf(75, 79, 85, 0, 0, 0), + byteArrayOf(75, 85, 0, 0, 0, 0), byteArrayOf(75, 85, 65, 0, 0, 0), + byteArrayOf(75, 85, 65, 73, 0, 0), byteArrayOf(75, 85, 65, 78, 0, 0), + byteArrayOf(75, 85, 65, 78, 71, 0), byteArrayOf(75, 85, 73, 0, 0, 0), + byteArrayOf(75, 85, 78, 0, 0, 0), byteArrayOf(75, 85, 79, 0, 0, 0), + byteArrayOf(76, 65, 0, 0, 0, 0), byteArrayOf(76, 65, 73, 0, 0, 0), + byteArrayOf(76, 65, 78, 0, 0, 0), byteArrayOf(76, 65, 78, 71, 0, 0), + byteArrayOf(76, 65, 79, 0, 0, 0), byteArrayOf(76, 69, 0, 0, 0, 0), + byteArrayOf(76, 69, 73, 0, 0, 0), byteArrayOf(76, 69, 78, 71, 0, 0), + byteArrayOf(76, 73, 0, 0, 0, 0), byteArrayOf(76, 73, 65, 0, 0, 0), + byteArrayOf(76, 73, 65, 78, 0, 0), byteArrayOf(76, 73, 65, 78, 71, 0), + byteArrayOf(76, 73, 65, 79, 0, 0), byteArrayOf(76, 73, 69, 0, 0, 0), + byteArrayOf(76, 73, 78, 0, 0, 0), byteArrayOf(76, 73, 78, 71, 0, 0), + byteArrayOf(76, 73, 85, 0, 0, 0), byteArrayOf(76, 79, 0, 0, 0, 0), + byteArrayOf(76, 79, 78, 71, 0, 0), byteArrayOf(76, 79, 85, 0, 0, 0), + byteArrayOf(76, 85, 0, 0, 0, 0), byteArrayOf(76, 85, 65, 78, 0, 0), + byteArrayOf(76, 85, 69, 0, 0, 0), byteArrayOf(76, 85, 78, 0, 0, 0), + byteArrayOf(76, 85, 79, 0, 0, 0), byteArrayOf(77, 0, 0, 0, 0, 0), + byteArrayOf(77, 65, 0, 0, 0, 0), byteArrayOf(77, 65, 73, 0, 0, 0), + byteArrayOf(77, 65, 78, 0, 0, 0), byteArrayOf(77, 65, 78, 71, 0, 0), + byteArrayOf(77, 65, 79, 0, 0, 0), byteArrayOf(77, 69, 0, 0, 0, 0), + byteArrayOf(77, 69, 73, 0, 0, 0), byteArrayOf(77, 69, 78, 0, 0, 0), + byteArrayOf(77, 69, 78, 71, 0, 0), byteArrayOf(77, 73, 0, 0, 0, 0), + byteArrayOf(77, 73, 65, 78, 0, 0), byteArrayOf(77, 73, 65, 79, 0, 0), + byteArrayOf(77, 73, 69, 0, 0, 0), byteArrayOf(77, 73, 78, 0, 0, 0), + byteArrayOf(77, 73, 78, 71, 0, 0), byteArrayOf(77, 73, 85, 0, 0, 0), + byteArrayOf(77, 79, 0, 0, 0, 0), byteArrayOf(77, 79, 85, 0, 0, 0), + byteArrayOf(77, 85, 0, 0, 0, 0), byteArrayOf(78, 0, 0, 0, 0, 0), + byteArrayOf(78, 65, 0, 0, 0, 0), byteArrayOf(78, 65, 73, 0, 0, 0), + byteArrayOf(78, 65, 78, 0, 0, 0), byteArrayOf(78, 65, 78, 71, 0, 0), + byteArrayOf(78, 65, 79, 0, 0, 0), byteArrayOf(78, 69, 0, 0, 0, 0), + byteArrayOf(78, 69, 73, 0, 0, 0), byteArrayOf(78, 69, 78, 0, 0, 0), + byteArrayOf(78, 69, 78, 71, 0, 0), byteArrayOf(78, 73, 0, 0, 0, 0), + byteArrayOf(78, 73, 65, 78, 0, 0), byteArrayOf(78, 73, 65, 78, 71, 0), + byteArrayOf(78, 73, 65, 79, 0, 0), byteArrayOf(78, 73, 69, 0, 0, 0), + byteArrayOf(78, 73, 78, 0, 0, 0), byteArrayOf(78, 73, 78, 71, 0, 0), + byteArrayOf(78, 73, 85, 0, 0, 0), byteArrayOf(78, 79, 78, 71, 0, 0), + byteArrayOf(78, 79, 85, 0, 0, 0), byteArrayOf(78, 85, 0, 0, 0, 0), + byteArrayOf(78, 85, 65, 78, 0, 0), byteArrayOf(78, 85, 69, 0, 0, 0), + byteArrayOf(78, 85, 78, 0, 0, 0), byteArrayOf(78, 85, 79, 0, 0, 0), + byteArrayOf(79, 0, 0, 0, 0, 0), byteArrayOf(79, 85, 0, 0, 0, 0), + byteArrayOf(80, 65, 0, 0, 0, 0), byteArrayOf(80, 65, 73, 0, 0, 0), + byteArrayOf(80, 65, 78, 0, 0, 0), byteArrayOf(80, 65, 78, 71, 0, 0), + byteArrayOf(80, 65, 79, 0, 0, 0), byteArrayOf(80, 69, 73, 0, 0, 0), + byteArrayOf(80, 69, 78, 0, 0, 0), byteArrayOf(80, 69, 78, 71, 0, 0), + byteArrayOf(80, 73, 0, 0, 0, 0), byteArrayOf(80, 73, 65, 78, 0, 0), + byteArrayOf(80, 73, 65, 79, 0, 0), byteArrayOf(80, 73, 69, 0, 0, 0), + byteArrayOf(80, 73, 78, 0, 0, 0), byteArrayOf(80, 73, 78, 71, 0, 0), + byteArrayOf(80, 79, 0, 0, 0, 0), byteArrayOf(80, 79, 85, 0, 0, 0), + byteArrayOf(80, 85, 0, 0, 0, 0), byteArrayOf(81, 73, 0, 0, 0, 0), + byteArrayOf(81, 73, 65, 0, 0, 0), byteArrayOf(81, 73, 65, 78, 0, 0), + byteArrayOf(81, 73, 65, 78, 71, 0), byteArrayOf(81, 73, 65, 79, 0, 0), + byteArrayOf(81, 73, 69, 0, 0, 0), byteArrayOf(81, 73, 78, 0, 0, 0), + byteArrayOf(81, 73, 78, 71, 0, 0), byteArrayOf(81, 73, 79, 78, 71, 0), + byteArrayOf(81, 73, 85, 0, 0, 0), byteArrayOf(81, 85, 0, 0, 0, 0), + byteArrayOf(81, 85, 65, 78, 0, 0), byteArrayOf(81, 85, 69, 0, 0, 0), + byteArrayOf(81, 85, 78, 0, 0, 0), byteArrayOf(82, 65, 78, 0, 0, 0), + byteArrayOf(82, 65, 78, 71, 0, 0), byteArrayOf(82, 65, 79, 0, 0, 0), + byteArrayOf(82, 69, 0, 0, 0, 0), byteArrayOf(82, 69, 78, 0, 0, 0), + byteArrayOf(82, 69, 78, 71, 0, 0), byteArrayOf(82, 73, 0, 0, 0, 0), + byteArrayOf(82, 79, 78, 71, 0, 0), byteArrayOf(82, 79, 85, 0, 0, 0), + byteArrayOf(82, 85, 0, 0, 0, 0), byteArrayOf(82, 85, 65, 0, 0, 0), + byteArrayOf(82, 85, 65, 78, 0, 0), byteArrayOf(82, 85, 73, 0, 0, 0), + byteArrayOf(82, 85, 78, 0, 0, 0), byteArrayOf(82, 85, 79, 0, 0, 0), + byteArrayOf(83, 65, 0, 0, 0, 0), byteArrayOf(83, 65, 73, 0, 0, 0), + byteArrayOf(83, 65, 78, 0, 0, 0), byteArrayOf(83, 65, 78, 71, 0, 0), + byteArrayOf(83, 65, 79, 0, 0, 0), byteArrayOf(83, 69, 0, 0, 0, 0), + byteArrayOf(83, 69, 78, 0, 0, 0), byteArrayOf(83, 69, 78, 71, 0, 0), + byteArrayOf(83, 72, 65, 0, 0, 0), byteArrayOf(83, 72, 65, 73, 0, 0), + byteArrayOf(83, 72, 65, 78, 0, 0), byteArrayOf(83, 72, 65, 78, 71, 0), + byteArrayOf(83, 72, 65, 79, 0, 0), byteArrayOf(83, 72, 69, 0, 0, 0), + byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(88, 73, 78, 0, 0, 0), + byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(83, 72, 69, 78, 71, 0), + byteArrayOf(83, 72, 73, 0, 0, 0), byteArrayOf(83, 72, 79, 85, 0, 0), + byteArrayOf(83, 72, 85, 0, 0, 0), byteArrayOf(83, 72, 85, 65, 0, 0), + byteArrayOf(83, 72, 85, 65, 73, 0), byteArrayOf(83, 72, 85, 65, 78, 0), + byteArrayOf(83, 72, 85, 65, 78, 71), byteArrayOf(83, 72, 85, 73, 0, 0), + byteArrayOf(83, 72, 85, 78, 0, 0), byteArrayOf(83, 72, 85, 79, 0, 0), + byteArrayOf(83, 73, 0, 0, 0, 0), byteArrayOf(83, 79, 78, 71, 0, 0), + byteArrayOf(83, 79, 85, 0, 0, 0), byteArrayOf(83, 85, 0, 0, 0, 0), + byteArrayOf(83, 85, 65, 78, 0, 0), byteArrayOf(83, 85, 73, 0, 0, 0), + byteArrayOf(83, 85, 78, 0, 0, 0), byteArrayOf(83, 85, 79, 0, 0, 0), + byteArrayOf(84, 65, 0, 0, 0, 0), byteArrayOf(84, 65, 73, 0, 0, 0), + byteArrayOf(84, 65, 78, 0, 0, 0), byteArrayOf(84, 65, 78, 71, 0, 0), + byteArrayOf(84, 65, 79, 0, 0, 0), byteArrayOf(84, 69, 0, 0, 0, 0), + byteArrayOf(84, 69, 78, 71, 0, 0), byteArrayOf(84, 73, 0, 0, 0, 0), + byteArrayOf(84, 73, 65, 78, 0, 0), byteArrayOf(84, 73, 65, 79, 0, 0), + byteArrayOf(84, 73, 69, 0, 0, 0), byteArrayOf(84, 73, 78, 71, 0, 0), + byteArrayOf(84, 79, 78, 71, 0, 0), byteArrayOf(84, 79, 85, 0, 0, 0), + byteArrayOf(84, 85, 0, 0, 0, 0), byteArrayOf(84, 85, 65, 78, 0, 0), + byteArrayOf(84, 85, 73, 0, 0, 0), byteArrayOf(84, 85, 78, 0, 0, 0), + byteArrayOf(84, 85, 79, 0, 0, 0), byteArrayOf(87, 65, 0, 0, 0, 0), + byteArrayOf(87, 65, 73, 0, 0, 0), byteArrayOf(87, 65, 78, 0, 0, 0), + byteArrayOf(87, 65, 78, 71, 0, 0), byteArrayOf(87, 69, 73, 0, 0, 0), + byteArrayOf(87, 69, 78, 0, 0, 0), byteArrayOf(87, 69, 78, 71, 0, 0), + byteArrayOf(87, 79, 0, 0, 0, 0), byteArrayOf(87, 85, 0, 0, 0, 0), + byteArrayOf(88, 73, 0, 0, 0, 0), byteArrayOf(88, 73, 65, 0, 0, 0), + byteArrayOf(88, 73, 65, 78, 0, 0), byteArrayOf(88, 73, 65, 78, 71, 0), + byteArrayOf(88, 73, 65, 79, 0, 0), byteArrayOf(88, 73, 69, 0, 0, 0), + byteArrayOf(88, 73, 78, 0, 0, 0), byteArrayOf(88, 73, 78, 71, 0, 0), + byteArrayOf(88, 73, 79, 78, 71, 0), byteArrayOf(88, 73, 85, 0, 0, 0), + byteArrayOf(88, 85, 0, 0, 0, 0), byteArrayOf(88, 85, 65, 78, 0, 0), + byteArrayOf(88, 85, 69, 0, 0, 0), byteArrayOf(88, 85, 78, 0, 0, 0), + byteArrayOf(89, 65, 0, 0, 0, 0), byteArrayOf(89, 65, 78, 0, 0, 0), + byteArrayOf(89, 65, 78, 71, 0, 0), byteArrayOf(89, 65, 79, 0, 0, 0), + byteArrayOf(89, 69, 0, 0, 0, 0), byteArrayOf(89, 73, 0, 0, 0, 0), + byteArrayOf(89, 73, 78, 0, 0, 0), byteArrayOf(89, 73, 78, 71, 0, 0), + byteArrayOf(89, 79, 0, 0, 0, 0), byteArrayOf(89, 79, 78, 71, 0, 0), + byteArrayOf(89, 79, 85, 0, 0, 0), byteArrayOf(89, 85, 0, 0, 0, 0), + byteArrayOf(89, 85, 65, 78, 0, 0), byteArrayOf(89, 85, 69, 0, 0, 0), + byteArrayOf(89, 85, 78, 0, 0, 0), byteArrayOf(74, 85, 78, 0, 0, 0), + byteArrayOf(89, 85, 78, 0, 0, 0), byteArrayOf(90, 65, 0, 0, 0, 0), + byteArrayOf(90, 65, 73, 0, 0, 0), byteArrayOf(90, 65, 78, 0, 0, 0), + byteArrayOf(90, 65, 78, 71, 0, 0), byteArrayOf(90, 65, 79, 0, 0, 0), + byteArrayOf(90, 69, 0, 0, 0, 0), byteArrayOf(90, 69, 73, 0, 0, 0), + byteArrayOf(90, 69, 78, 0, 0, 0), byteArrayOf(90, 69, 78, 71, 0, 0), + byteArrayOf(90, 72, 65, 0, 0, 0), byteArrayOf(90, 72, 65, 73, 0, 0), + byteArrayOf(90, 72, 65, 78, 0, 0), byteArrayOf(90, 72, 65, 78, 71, 0), + byteArrayOf(67, 72, 65, 78, 71, 0), byteArrayOf(90, 72, 65, 78, 71, 0), + byteArrayOf(90, 72, 65, 79, 0, 0), byteArrayOf(90, 72, 69, 0, 0, 0), + byteArrayOf(90, 72, 69, 78, 0, 0), byteArrayOf(90, 72, 69, 78, 71, 0), + byteArrayOf(90, 72, 73, 0, 0, 0), byteArrayOf(83, 72, 73, 0, 0, 0), + byteArrayOf(90, 72, 73, 0, 0, 0), byteArrayOf(90, 72, 79, 78, 71, 0), + byteArrayOf(90, 72, 79, 85, 0, 0), byteArrayOf(90, 72, 85, 0, 0, 0), + byteArrayOf(90, 72, 85, 65, 0, 0), byteArrayOf(90, 72, 85, 65, 73, 0), + byteArrayOf(90, 72, 85, 65, 78, 0), byteArrayOf(90, 72, 85, 65, 78, 71), + byteArrayOf(90, 72, 85, 73, 0, 0), byteArrayOf(90, 72, 85, 78, 0, 0), + byteArrayOf(90, 72, 85, 79, 0, 0), byteArrayOf(90, 73, 0, 0, 0, 0), + byteArrayOf(90, 79, 78, 71, 0, 0), byteArrayOf(90, 79, 85, 0, 0, 0), + byteArrayOf(90, 85, 0, 0, 0, 0), byteArrayOf(90, 85, 65, 78, 0, 0), + byteArrayOf(90, 85, 73, 0, 0, 0), byteArrayOf(90, 85, 78, 0, 0, 0), + byteArrayOf(90, 85, 79, 0, 0, 0), byteArrayOf(0, 0, 0, 0, 0, 0), + byteArrayOf(83, 72, 65, 78, 0, 0), byteArrayOf(0, 0, 0, 0, 0, 0) + ) + + private const val FIRST_PINYIN_UNIHAN = "阿" + private const val LAST_PINYIN_UNIHAN = "鿿" + + private val COLLATOR: Collator = Collator.getInstance(Locale.CHINA) + + private var sInstance: HanziToPinyin? = null + + fun getInstance(): HanziToPinyin { + synchronized(HanziToPinyin::class.java) { + if (sInstance != null) { + return sInstance!! + } + + val locale = Collator.getAvailableLocales() + for (value in locale) { + if (value == Locale.CHINA || value.language.contains("zh")) { + if (DEBUG) { + Log.d(TAG, "Self validation. Result: ${doSelfValidation()}") + } + sInstance = HanziToPinyin(true) + return sInstance!! + } + } + + if (sInstance == null) { + if (Locale.CHINA == Locale.getDefault()) { + sInstance = HanziToPinyin(true) + return sInstance!! + } + } + + Log.w(TAG, "There is no Chinese collator, HanziToPinyin is disabled") + sInstance = HanziToPinyin(false) + return sInstance!! + } + } + + private fun doSelfValidation(): Boolean { + val lastChar = UNIHANS[0] + var lastString = lastChar.toString() + for (c in UNIHANS) { + if (lastChar == c) { + continue + } + val curString = c.toString() + val cmp = COLLATOR.compare(lastString, curString) + if (cmp >= 0) { + Log.e( + TAG, + "Internal error in Unihan table. The last string \"$lastString\" " + + "is greater than current string \"$curString\"." + ) + return false + } + lastString = curString + } + return true + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/HyperlinkText.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/HyperlinkText.kt new file mode 100644 index 0000000..36ea19c --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/HyperlinkText.kt @@ -0,0 +1,88 @@ +package com.sukisu.ultra.ui.util + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import java.util.regex.Pattern + +@Composable +fun LinkifyText( + text: String, + modifier: Modifier = Modifier +) { + val uriHandler = LocalUriHandler.current + val layoutResult = remember { + mutableStateOf(null) + } + val linksList = extractUrls(text) + val annotatedString = buildAnnotatedString { + append(text) + linksList.forEach { + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ), + start = it.start, + end = it.end + ) + addStringAnnotation( + tag = "URL", + annotation = it.url, + start = it.start, + end = it.end + ) + } + } + Text( + text = annotatedString, + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offsetPosition -> + layoutResult.value?.let { + val position = it.getOffsetForPosition(offsetPosition) + annotatedString.getStringAnnotations(position, position).firstOrNull() + ?.let { result -> + if (result.tag == "URL") { + uriHandler.openUri(result.item) + } + } + } + } + }, + onTextLayout = { layoutResult.value = it } + ) +} + +private val urlPattern: Pattern = Pattern.compile( + "(?:^|\\W)((ht|f)tp(s?)://|www\\.)" + + "(([\\w\\-]+\\.)+([\\w\\-.~]+/?)*" + + "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]*$~@!:/{};']*)", + Pattern.CASE_INSENSITIVE or Pattern.MULTILINE or Pattern.DOTALL +) + +private data class LinkInfo( + val url: String, + val start: Int, + val end: Int +) + +@Suppress("HttpUrlsUsage") +private fun extractUrls(text: String): List = buildList { + val matcher = urlPattern.matcher(text) + while (matcher.find()) { + val matchStart = matcher.start(1) + val matchEnd = matcher.end() + val url = text.substring(matchStart, matchEnd).replaceFirst("http://", "https://") + add(LinkInfo(url, matchStart, matchEnd)) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt new file mode 100644 index 0000000..d7398d6 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt @@ -0,0 +1,738 @@ +package com.sukisu.ultra.ui.util + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Environment +import android.os.Parcelable +import android.os.SystemClock +import android.provider.OpenableColumns +import android.system.Os +import android.util.Log +import com.topjohnwu.superuser.CallbackList +import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.ShellUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import com.sukisu.ultra.BuildConfig +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ksuApp +import org.json.JSONArray +import java.io.File + + +/** + * @author weishu + * @date 2023/1/1. + */ +private const val TAG = "KsuCli" + +private fun getKsuDaemonPath(): String { + return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so" +} + +object KsuCli { + var SHELL: Shell = createRootShell() + val GLOBAL_MNT_SHELL: Shell = createRootShell(true) +} + +fun getRootShell(globalMnt: Boolean = false): Shell { + return if (globalMnt) KsuCli.GLOBAL_MNT_SHELL else { + KsuCli.SHELL + } +} + +inline fun withNewRootShell( + globalMnt: Boolean = false, + block: Shell.() -> T +): T { + return createRootShell(globalMnt).use(block) +} + +fun Uri.getFileName(context: Context): String? { + var fileName: String? = null + val contentResolver: ContentResolver = context.contentResolver + val cursor: Cursor? = contentResolver.query(this, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + fileName = it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + } + } + return fileName +} + +fun createRootShell(globalMnt: Boolean = false): Shell { + Shell.enableVerboseLogging = BuildConfig.DEBUG + val builder = Shell.Builder.create() + return try { + if (globalMnt) { + builder.build(getKsuDaemonPath(), "debug", "su", "-g") + } else { + builder.build(getKsuDaemonPath(), "debug", "su") + } + } catch (e: Throwable) { + Log.w(TAG, "ksu failed: ", e) + try { + if (globalMnt) { + builder.build("su", "-mm") + } else { + builder.build("su") + } + } catch (e: Throwable) { + Log.e(TAG, "su failed: ", e) + builder.build("sh") + } + } +} + +fun execKsud(args: String, newShell: Boolean = false): Boolean { + return if (newShell) { + withNewRootShell { + ShellUtils.fastCmdResult(this, "${getKsuDaemonPath()} $args") + } + } else { + ShellUtils.fastCmdResult(getRootShell(), "${getKsuDaemonPath()} $args") + } +} + +suspend fun getFeatureStatus(feature: String): String = withContext(Dispatchers.IO) { + val shell = getRootShell() + val out = shell.newJob() + .add("${getKsuDaemonPath()} feature check $feature").to(ArrayList(), null).exec().out + out.firstOrNull()?.trim().orEmpty() +} + +fun install() { + val start = SystemClock.elapsedRealtime() + val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so").absolutePath + val result = execKsud("install --magiskboot $magiskboot", true) + Log.w(TAG, "install result: $result, cost: ${SystemClock.elapsedRealtime() - start}ms") +} + +fun listModules(): String { + val shell = getRootShell() + + val out = shell.newJob() + .add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out + return out.joinToString("\n").ifBlank { "[]" } +} + +fun getModuleCount(): Int { + val result = listModules() + runCatching { + val array = JSONArray(result) + return array.length() + }.getOrElse { return 0 } +} + +fun getSuperuserCount(): Int { + return Natives.allowList.size +} + +fun toggleModule(id: String, enable: Boolean): Boolean { + val cmd = if (enable) { + "module enable $id" + } else { + "module disable $id" + } + val result = execKsud(cmd, true) + Log.i(TAG, "$cmd result: $result") + return result +} + +fun uninstallModule(id: String): Boolean { + val cmd = "module uninstall $id" + val result = execKsud(cmd, true) + Log.i(TAG, "uninstall module $id result: $result") + return result +} + +fun restoreModule(id: String): Boolean { + val cmd = "module restore $id" + val result = execKsud(cmd, true) + Log.i(TAG, "restore module $id result: $result") + return result +} + +fun undoUninstallModule(id: String): Boolean { + val cmd = "module undo-uninstall $id" + val result = execKsud(cmd, true) + Log.i(TAG, "undo uninstall module $id result: $result") + return result +} + +private fun flashWithIO( + cmd: String, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +): Shell.Result { + + val stdoutCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStdout(s ?: "") + } + } + + val stderrCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStderr(s ?: "") + } + } + + return withNewRootShell { + newJob().add(cmd).to(stdoutCallback, stderrCallback).exec() + } +} + +fun flashModule( + uri: Uri, + onFinish: (Boolean, Int) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +): Boolean { + val resolver = ksuApp.contentResolver + with(resolver.openInputStream(uri)) { + val file = File(ksuApp.cacheDir, "module.zip") + file.outputStream().use { output -> + this?.copyTo(output) + } + val cmd = "module install ${file.absolutePath}" + val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr) + Log.i("KernelSU", "install module $uri result: $result") + + file.delete() + + onFinish(result.isSuccess, result.code) + return result.isSuccess + } +} + +fun runModuleAction( + moduleId: String, onStdout: (String) -> Unit, onStderr: (String) -> Unit +): Boolean { + val shell = createRootShell(true) + + val stdoutCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStdout(s ?: "") + } + } + + val stderrCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStderr(s ?: "") + } + } + + val result = shell.newJob().add("${getKsuDaemonPath()} module action $moduleId") + .to(stdoutCallback, stderrCallback).exec() + Log.i("KernelSU", "Module runAction result: $result") + + return result.isSuccess +} + +fun restoreBoot( + onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit +): Boolean { + val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so") + val result = flashWithIO( + "${getKsuDaemonPath()} boot-restore -f --magiskboot $magiskboot", + onStdout, + onStderr + ) + onFinish(result.isSuccess, result.code) + return result.isSuccess +} + +fun uninstallPermanently( + onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit +): Boolean { + val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so") + val result = + flashWithIO("${getKsuDaemonPath()} uninstall --magiskboot $magiskboot", onStdout, onStderr) + onFinish(result.isSuccess, result.code) + return result.isSuccess +} + +@Parcelize +sealed class LkmSelection : Parcelable { + data class LkmUri(val uri: Uri) : LkmSelection() + data class KmiString(val value: String) : LkmSelection() + data object KmiNone : LkmSelection() +} + +fun installBoot( + bootUri: Uri?, + lkm: LkmSelection, + ota: Boolean, + partition: String?, + onFinish: (Boolean, Int) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit, +): Boolean { + val resolver = ksuApp.contentResolver + + val bootFile = bootUri?.let { uri -> + with(resolver.openInputStream(uri)) { + val bootFile = File(ksuApp.cacheDir, "boot.img") + bootFile.outputStream().use { output -> + this?.copyTo(output) + } + + bootFile + } + } + + val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so") + var cmd = "boot-patch --magiskboot ${magiskboot.absolutePath}" + + cmd += if (bootFile == null) { + // no boot.img, use -f to force install + " -f" + } else { + " -b ${bootFile.absolutePath}" + } + + if (ota) { + cmd += " -u" + } + + var lkmFile: File? = null + when (lkm) { + is LkmSelection.LkmUri -> { + lkmFile = with(resolver.openInputStream(lkm.uri)) { + val file = File(ksuApp.cacheDir, "kernelsu-tmp-lkm.ko") + file.outputStream().use { output -> + this?.copyTo(output) + } + + file + } + cmd += " -m ${lkmFile.absolutePath}" + } + + is LkmSelection.KmiString -> { + cmd += " --kmi ${lkm.value}" + } + + LkmSelection.KmiNone -> { + // do nothing + } + } + + // output dir + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + cmd += " -o $downloadsDir" + + partition?.let { part -> + cmd += " --partition $part" + } + + val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr) + Log.i("KernelSU", "install boot result: ${result.isSuccess}") + + bootFile?.delete() + lkmFile?.delete() + + // if boot uri is empty, it is direct install, when success, we should show reboot button + onFinish(bootUri == null && result.isSuccess, result.code) + + if (bootUri == null && result.isSuccess) { + install() + } + + return result.isSuccess +} + +fun reboot(reason: String = "") { + val shell = getRootShell() + if (reason == "recovery") { + // KEYCODE_POWER = 26, hide incorrect "Factory data reset" message + ShellUtils.fastCmd(shell, "/system/bin/input keyevent 26") + } + ShellUtils.fastCmd(shell, "/system/bin/svc power reboot $reason || /system/bin/reboot $reason") +} + +fun rootAvailable(): Boolean { + val shell = getRootShell() + return shell.isRoot +} + + +suspend fun getCurrentKmi(): String = withContext(Dispatchers.IO) { + val shell = getRootShell() + val cmd = "boot-info current-kmi" + ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd") +} + +suspend fun getSupportedKmis(): List = withContext(Dispatchers.IO) { + val shell = getRootShell() + val cmd = "boot-info supported-kmis" + val out = shell.newJob().add("${getKsuDaemonPath()} $cmd").to(ArrayList(), null).exec().out + out.filter { it.isNotBlank() }.map { it.trim() } +} + +suspend fun isAbDevice(): Boolean = withContext(Dispatchers.IO) { + val shell = getRootShell() + val cmd = "boot-info is-ab-device" + ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd").trim().toBoolean() +} + +suspend fun getDefaultPartition(): String = withContext(Dispatchers.IO) { + val shell = getRootShell() + if (shell.isRoot) { + val cmd = "boot-info default-partition" + ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd").trim() + } else { + if (!Os.uname().release.contains("android12-")) "init_boot" else "boot" + } +} + +suspend fun getSlotSuffix(ota: Boolean): String = withContext(Dispatchers.IO) { + val shell = getRootShell() + val cmd = if (ota) { + "boot-info slot-suffix --ota" + } else { + "boot-info slot-suffix" + } + ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd").trim() +} + +suspend fun getAvailablePartitions(): List = withContext(Dispatchers.IO) { + val shell = getRootShell() + val cmd = "boot-info available-partitions" + val out = shell.newJob().add("${getKsuDaemonPath()} $cmd").to(ArrayList(), null).exec().out + out.filter { it.isNotBlank() }.map { it.trim() } +} + +fun hasMagisk(): Boolean { + val shell = getRootShell(true) + val result = shell.newJob().add("which magisk").exec() + Log.i(TAG, "has magisk: ${result.isSuccess}") + return result.isSuccess +} + +fun isSepolicyValid(rules: String?): Boolean { + if (rules == null) { + return true + } + val shell = getRootShell() + val result = + shell.newJob().add("${getKsuDaemonPath()} sepolicy check '$rules'").to(ArrayList(), null) + .exec() + return result.isSuccess +} + +fun getSepolicy(pkg: String): String { + val shell = getRootShell() + val result = + shell.newJob().add("${getKsuDaemonPath()} profile get-sepolicy $pkg").to(ArrayList(), null) + .exec() + Log.i(TAG, "code: ${result.code}, out: ${result.out}, err: ${result.err}") + return result.out.joinToString("\n") +} + +fun setSepolicy(pkg: String, rules: String): Boolean { + val shell = getRootShell() + val result = shell.newJob().add("${getKsuDaemonPath()} profile set-sepolicy $pkg '$rules'") + .to(ArrayList(), null).exec() + Log.i(TAG, "set sepolicy result: ${result.code}") + return result.isSuccess +} + +fun listAppProfileTemplates(): List { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile list-templates").to(ArrayList(), null) + .exec().out +} + +fun getAppProfileTemplate(id: String): String { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile get-template '${id}'") + .to(ArrayList(), null).exec().out.joinToString("\n") +} + +fun setAppProfileTemplate(id: String, template: String): Boolean { + val shell = getRootShell() + val escapedTemplate = template.replace("\"", "\\\"") + val cmd = """${getKsuDaemonPath()} profile set-template "$id" "$escapedTemplate'"""" + return shell.newJob().add(cmd) + .to(ArrayList(), null).exec().isSuccess +} + +fun deleteAppProfileTemplate(id: String): Boolean { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile delete-template '${id}'") + .to(ArrayList(), null).exec().isSuccess +} +// KPM控制 +fun loadKpmModule(path: String, args: String? = null): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} kpm load $path ${args ?: ""}" + return ShellUtils.fastCmd(shell, cmd) +} + +fun unloadKpmModule(name: String): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} kpm unload $name" + return ShellUtils.fastCmd(shell, cmd) +} + +fun getKpmModuleCount(): Int { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} kpm num" + val result = ShellUtils.fastCmd(shell, cmd) + return result.trim().toIntOrNull() ?: 0 +} + +fun runCmd(shell: Shell, cmd: String): String { + return shell.newJob() + .add(cmd) + .to(mutableListOf(), null) + .exec().out + .joinToString("\n") +} + +fun listKpmModules(): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} kpm list" + return try { + runCmd(shell, cmd).trim() + } catch (e: Exception) { + Log.e(TAG, "Failed to list KPM modules", e) + "" + } +} + +fun getKpmModuleInfo(name: String): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} kpm info $name" + return try { + runCmd(shell, cmd).trim() + } catch (e: Exception) { + Log.e(TAG, "Failed to get KPM module info: $name", e) + "" + } +} + +fun controlKpmModule(name: String, args: String? = null): Int { + val shell = getRootShell() + val cmd = """${getKsuDaemonPath()} kpm control $name "${args ?: ""}"""" + val result = runCmd(shell, cmd) + return result.trim().toIntOrNull() ?: -1 +} + +fun getKpmVersion(): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} kpm version" + val result = ShellUtils.fastCmd(shell, cmd) + return result.trim() +} + +fun forceStopApp(packageName: String) { + val shell = getRootShell() + val result = shell.newJob().add("am force-stop $packageName").exec() + Log.i(TAG, "force stop $packageName result: $result") +} + +fun launchApp(packageName: String) { + + val shell = getRootShell() + val result = + shell.newJob() + .add("cmd package resolve-activity --brief $packageName | tail -n 1 | xargs cmd activity start-activity -n") + .exec() + Log.i(TAG, "launch $packageName result: $result") +} + +fun restartApp(packageName: String) { + forceStopApp(packageName) + launchApp(packageName) +} + +fun getSuSFSDaemonPath(): String { + return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksu_susfs.so" +} + +fun getSuSFSVersion(): String { + val shell = getRootShell() + val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show version") + return result +} + +fun getSuSFSVariant(): String { + val shell = getRootShell() + val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show variant") + return result +} + +fun getSuSFSFeatures(): String { + val shell = getRootShell() + val cmd = "${getSuSFSDaemonPath()} show enabled_features" + return runCmd(shell, cmd) +} + +fun getZygiskImplement(): String { + val shell = getRootShell() + + val zygiskModuleIds = listOf( + "zygisksu", + "rezygisk", + "shirokozygisk" + ) + + for (moduleId in zygiskModuleIds) { + val modulePath = "/data/adb/modules/$moduleId" + when { + ShellUtils.fastCmdResult(shell, "test -f $modulePath/module.prop && test ! -f $modulePath/disable") -> { + val result = ShellUtils.fastCmd(shell, "grep '^name=' $modulePath/module.prop | cut -d'=' -f2") + Log.i(TAG, "Zygisk implement: $result") + return result + } + } + } + + Log.i(TAG, "Zygisk implement: None") + return "None" +} + +fun getUidScannerDaemonPath(): String { + return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libuid_scanner.so" +} + +private const val targetPath = "/data/adb/uid_scanner" +fun ensureUidScannerExecutable(): Boolean { + val shell = getRootShell() + val uidScannerPath = getUidScannerDaemonPath() + if (!ShellUtils.fastCmdResult(shell, "test -f $targetPath")) { + val copyResult = ShellUtils.fastCmdResult(shell, "cp $uidScannerPath $targetPath") + if (!copyResult) { + return false + } + } + + val result = ShellUtils.fastCmdResult(shell, "chmod 755 $targetPath") + return result +} + +fun setUidAutoScan(enabled: Boolean): Boolean { + val shell = getRootShell() + if (!ensureUidScannerExecutable()) { + return false + } + + val enableValue = if (enabled) 1 else 0 + val cmd = "$targetPath --auto-scan $enableValue && $targetPath reload" + val result = ShellUtils.fastCmdResult(shell, cmd) + + val throneResult = Natives.setUidScannerEnabled(enabled) + + return result && throneResult +} + +fun setUidMultiUserScan(enabled: Boolean): Boolean { + val shell = getRootShell() + if (!ensureUidScannerExecutable()) { + return false + } + + val enableValue = if (enabled) 1 else 0 + val cmd = "$targetPath --multi-user $enableValue && $targetPath reload" + val result = ShellUtils.fastCmdResult(shell, cmd) + return result +} + +fun getUidMultiUserScan(): Boolean { + val shell = getRootShell() + + val cmd = "grep 'multi_user_scan=' /data/misc/user_uid/uid_scanner.conf | cut -d'=' -f2" + val result = ShellUtils.fastCmd(shell, cmd).trim() + + return try { + result.toInt() == 1 + } catch (_: NumberFormatException) { + false + } +} + +fun cleanRuntimeEnvironment(): Boolean { + val shell = getRootShell() + return try { + try { + ShellUtils.fastCmd(shell, "/data/adb/uid_scanner stop") + } catch (_: Exception) { + } + ShellUtils.fastCmdResult(shell, "rm -rf /data/misc/user_uid") + ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/uid_scanner") + ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/ksu/bin/user_uid") + ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/service.d/uid_scanner.sh") + Natives.clearUidScannerEnvironment() + true + } catch (_: Exception) { + false + } +} + +fun readUidScannerFile(): Boolean { + val shell = getRootShell() + return try { + ShellUtils.fastCmd(shell, "cat /data/adb/ksu/.uid_scanner").trim() == "1" + } catch (_: Exception) { + false + } +} + +fun addUmountPath(path: String, flags: Int): Boolean { + val shell = getRootShell() + val flagsArg = if (flags >= 0) "--flags $flags" else "" + val cmd = "${getKsuDaemonPath()} umount add $path $flagsArg" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "add umount path $path result: $result") + return result +} + +fun removeUmountPath(path: String): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount remove $path" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "remove umount path $path result: $result") + return result +} + +fun listUmountPaths(): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount list" + return try { + runCmd(shell, cmd).trim() + } catch (e: Exception) { + Log.e(TAG, "Failed to list umount paths", e) + "" + } +} + +fun clearCustomUmountPaths(): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount clear-custom" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "clear custom umount paths result: $result") + return result +} + +fun saveUmountConfig(): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount save" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "save umount config result: $result") + return result +} + +fun applyUmountConfigToKernel(): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount apply" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "apply umount config to kernel result: $result") + return result +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/LogEvent.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/LogEvent.kt new file mode 100644 index 0000000..758cc2b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/LogEvent.kt @@ -0,0 +1,111 @@ +package com.sukisu.ultra.ui.util + +import android.content.Context +import android.os.Build +import android.system.Os +import com.topjohnwu.superuser.ShellUtils +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ui.screen.getManagerVersion +import java.io.File +import java.io.FileWriter +import java.io.PrintWriter +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +fun getBugreportFile(context: Context): File { + + val bugreportDir = File(context.cacheDir, "bugreport") + bugreportDir.mkdirs() + + val dmesgFile = File(bugreportDir, "dmesg.txt") + val logcatFile = File(bugreportDir, "logcat.txt") + val tombstonesFile = File(bugreportDir, "tombstones.tar.gz") + val dropboxFile = File(bugreportDir, "dropbox.tar.gz") + val pstoreFile = File(bugreportDir, "pstore.tar.gz") + // Xiaomi/Readmi devices have diag in /data/vendor/diag + val diagFile = File(bugreportDir, "diag.tar.gz") + val oplusFile = File(bugreportDir, "oplus.tar.gz") + val bootlogFile = File(bugreportDir, "bootlog.tar.gz") + val mountsFile = File(bugreportDir, "mounts.txt") + val fileSystemsFile = File(bugreportDir, "filesystems.txt") + val adbFileTree = File(bugreportDir, "adb_tree.txt") + val adbFileDetails = File(bugreportDir, "adb_details.txt") + val ksuFileSize = File(bugreportDir, "ksu_size.txt") + val appListFile = File(bugreportDir, "packages.txt") + val propFile = File(bugreportDir, "props.txt") + val allowListFile = File(bugreportDir, "allowlist.bin") + val procModules = File(bugreportDir, "proc_modules.txt") + val bootConfig = File(bugreportDir, "boot_config.txt") + val kernelConfig = File(bugreportDir, "defconfig.gz") + + val shell = getRootShell(true) + + shell.newJob().add("dmesg > ${dmesgFile.absolutePath}").exec() + shell.newJob().add("logcat -d > ${logcatFile.absolutePath}").exec() + shell.newJob().add("tar -czf ${tombstonesFile.absolutePath} -C /data/tombstones .").exec() + shell.newJob().add("tar -czf ${dropboxFile.absolutePath} -C /data/system/dropbox .").exec() + shell.newJob().add("tar -czf ${pstoreFile.absolutePath} -C /sys/fs/pstore .").exec() + shell.newJob().add("tar -czf ${diagFile.absolutePath} -C /data/vendor/diag . --exclude=./minidump.gz").exec() + shell.newJob().add("tar -czf ${oplusFile.absolutePath} -C /mnt/oplus/op2/media/log/boot_log/ .").exec() + shell.newJob().add("tar -czf ${bootlogFile.absolutePath} -C /data/adb/ksu/log .").exec() + + shell.newJob().add("cat /proc/1/mountinfo > ${mountsFile.absolutePath}").exec() + shell.newJob().add("cat /proc/filesystems > ${fileSystemsFile.absolutePath}").exec() + shell.newJob().add("busybox tree /data/adb > ${adbFileTree.absolutePath}").exec() + shell.newJob().add("ls -alRZ /data/adb > ${adbFileDetails.absolutePath}").exec() + shell.newJob().add("du -sh /data/adb/ksu/* > ${ksuFileSize.absolutePath}").exec() + shell.newJob().add("cp /data/system/packages.list ${appListFile.absolutePath}").exec() + shell.newJob().add("getprop > ${propFile.absolutePath}").exec() + shell.newJob().add("cp /data/adb/ksu/.allowlist ${allowListFile.absolutePath}").exec() + shell.newJob().add("cp /proc/modules ${procModules.absolutePath}").exec() + shell.newJob().add("cp /proc/bootconfig ${bootConfig.absolutePath}").exec() + shell.newJob().add("cp /proc/config.gz ${kernelConfig.absolutePath}").exec() + + val selinux = ShellUtils.fastCmd(shell, "getenforce") + + // basic information + val buildInfo = File(bugreportDir, "basic.txt") + PrintWriter(FileWriter(buildInfo)).use { pw -> + pw.println("Kernel: ${System.getProperty("os.version")}") + pw.println("BRAND: " + Build.BRAND) + pw.println("MODEL: " + Build.MODEL) + pw.println("PRODUCT: " + Build.PRODUCT) + pw.println("MANUFACTURER: " + Build.MANUFACTURER) + pw.println("SDK: " + Build.VERSION.SDK_INT) + pw.println("PREVIEW_SDK: " + Build.VERSION.PREVIEW_SDK_INT) + pw.println("FINGERPRINT: " + Build.FINGERPRINT) + pw.println("DEVICE: " + Build.DEVICE) + pw.println("Manager: " + getManagerVersion(context)) + pw.println("SELinux: $selinux") + + val uname = Os.uname() + pw.println("KernelRelease: ${uname.release}") + pw.println("KernelVersion: ${uname.version}") + pw.println("Machine: ${uname.machine}") + pw.println("Nodename: ${uname.nodename}") + pw.println("Sysname: ${uname.sysname}") + + val ksuKernel = Natives.version + pw.println("KernelSU: $ksuKernel") + val safeMode = Natives.isSafeMode + pw.println("SafeMode: $safeMode") + val lkmMode = Natives.isLkmMode + pw.println("LKM: $lkmMode") + } + + // modules + val modulesFile = File(bugreportDir, "modules.json") + modulesFile.writeText(listModules()) + + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm") + val current = LocalDateTime.now().format(formatter) + + val targetFile = File(context.cacheDir, "KernelSU_bugreport_${current}.tar.gz") + + shell.newJob().add("tar czf ${targetFile.absolutePath} -C ${bugreportDir.absolutePath} .").exec() + shell.newJob().add("rm -rf ${bugreportDir.absolutePath}").exec() + shell.newJob().add("chmod 0644 ${targetFile.absolutePath}").exec() + + return targetFile +} + diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/SELinuxChecker.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/SELinuxChecker.kt new file mode 100644 index 0000000..b7e5216 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/SELinuxChecker.kt @@ -0,0 +1,19 @@ +package com.sukisu.ultra.ui.util + +import android.content.Context +import com.sukisu.ultra.R +import com.topjohnwu.superuser.io.SuFile + +fun getSELinuxStatus(context: Context) = SuFile("/sys/fs/selinux/enforce").run { + when { + !exists() -> context.getString(R.string.selinux_status_disabled) + !isFile -> context.getString(R.string.selinux_status_unknown) + !canRead() -> context.getString(R.string.selinux_status_enforcing) + else -> when (runCatching { newInputStream() }.getOrNull()?.bufferedReader() + ?.use { it.runCatching { readLine() }.getOrNull()?.trim()?.toIntOrNull() }) { + 1 -> context.getString(R.string.selinux_status_enforcing) + 0 -> context.getString(R.string.selinux_status_permissive) + else -> context.getString(R.string.selinux_status_unknown) + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/LatestVersionInfo.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/LatestVersionInfo.kt new file mode 100644 index 0000000..6c134a5 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/LatestVersionInfo.kt @@ -0,0 +1,8 @@ +package com.sukisu.ultra.ui.util.module + +data class LatestVersionInfo( + val versionCode : Int = 0, + val downloadUrl : String = "", + val changelog : String = "", + val versionName: String = "" +) diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleModify.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleModify.kt new file mode 100644 index 0000000..c0f52b1 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleModify.kt @@ -0,0 +1,457 @@ +package com.sukisu.ultra.ui.util.module + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.util.reboot +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.* + +object ModuleModify { + @Composable + fun RestoreConfirmationDialog( + showDialog: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit + ) { + val context = LocalContext.current + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = context.getString(R.string.restore_confirm_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = context.getString(R.string.restore_confirm_message), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(context.getString(R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(context.getString(R.string.cancel)) + } + } + ) + } + } + + @Composable + fun AllowlistRestoreConfirmationDialog( + showDialog: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit + ) { + val context = LocalContext.current + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = context.getString(R.string.allowlist_restore_confirm_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = context.getString(R.string.allowlist_restore_confirm_message), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(context.getString(R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(context.getString(R.string.cancel)) + } + } + ) + } + } + + suspend fun backupModules(context: Context, snackBarHost: SnackbarHostState, uri: Uri) { + withContext(Dispatchers.IO) { + try { + val busyboxPath = "/data/adb/ksu/bin/busybox" + val moduleDir = "/data/adb/modules" + + // 直接将tar输出重定向到用户选择的文件 + val command = """ + cd "$moduleDir" && + $busyboxPath tar -cz ./* > /proc/self/fd/1 + """.trimIndent() + + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + + // 直接将tar输出写入到用户选择的文件 + context.contentResolver.openOutputStream(uri)?.use { output -> + process.inputStream.copyTo(output) + } + + val error = BufferedReader(InputStreamReader(process.errorStream)).readText() + if (process.exitValue() != 0) { + throw IOException(context.getString(R.string.command_execution_failed, error)) + } + + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + context.getString(R.string.backup_success), + duration = SnackbarDuration.Long + ) + } + + } catch (e: Exception) { + Log.e("Backup", context.getString(R.string.backup_failed, ""), e) + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + context.getString(R.string.backup_failed, e.message), + duration = SnackbarDuration.Long + ) + } + } + } + } + + suspend fun restoreModules( + context: Context, + snackBarHost: SnackbarHostState, + uri: Uri, + showConfirmDialog: (Boolean) -> Unit, + confirmResult: CompletableDeferred + ) { + // 显示确认对话框 + withContext(Dispatchers.Main) { + showConfirmDialog(true) + } + + val userConfirmed = confirmResult.await() + if (!userConfirmed) return + + withContext(Dispatchers.IO) { + try { + val busyboxPath = "/data/adb/ksu/bin/busybox" + val moduleDir = "/data/adb/modules" + + // 直接从用户选择的文件读取并解压 + val process = Runtime.getRuntime() + .exec(arrayOf("su", "-c", "$busyboxPath tar -xz -C $moduleDir")) + + context.contentResolver.openInputStream(uri)?.use { input -> + input.copyTo(process.outputStream) + } + process.outputStream.close() + + process.waitFor() + + val error = BufferedReader(InputStreamReader(process.errorStream)).readText() + if (process.exitValue() != 0) { + throw IOException(context.getString(R.string.command_execution_failed, error)) + } + + withContext(Dispatchers.Main) { + val snackbarResult = snackBarHost.showSnackbar( + message = context.getString(R.string.restore_success), + actionLabel = context.getString(R.string.restart_now), + duration = SnackbarDuration.Long + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + reboot() + } + } + + } catch (e: Exception) { + Log.e("Restore", context.getString(R.string.restore_failed, ""), e) + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + message = context.getString( + R.string.restore_failed, + e.message ?: context.getString(R.string.unknown_error) + ), + duration = SnackbarDuration.Long + ) + } + } + } + } + + suspend fun backupAllowlist(context: Context, snackBarHost: SnackbarHostState, uri: Uri) { + withContext(Dispatchers.IO) { + try { + val allowlistPath = "/data/adb/ksu/.allowlist" + + // 直接复制文件到用户选择的位置 + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $allowlistPath")) + + context.contentResolver.openOutputStream(uri)?.use { output -> + process.inputStream.copyTo(output) + } + + val error = BufferedReader(InputStreamReader(process.errorStream)).readText() + if (process.exitValue() != 0) { + throw IOException(context.getString(R.string.command_execution_failed, error)) + } + + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + context.getString(R.string.allowlist_backup_success), + duration = SnackbarDuration.Long + ) + } + + } catch (e: Exception) { + Log.e("AllowlistBackup", context.getString(R.string.allowlist_backup_failed, ""), e) + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + context.getString(R.string.allowlist_backup_failed, e.message), + duration = SnackbarDuration.Long + ) + } + } + } + } + + suspend fun restoreAllowlist( + context: Context, + snackBarHost: SnackbarHostState, + uri: Uri, + showConfirmDialog: (Boolean) -> Unit, + confirmResult: CompletableDeferred + ) { + // 显示确认对话框 + withContext(Dispatchers.Main) { + showConfirmDialog(true) + } + + val userConfirmed = confirmResult.await() + if (!userConfirmed) return + + withContext(Dispatchers.IO) { + try { + val allowlistPath = "/data/adb/ksu/.allowlist" + + // 直接从用户选择的文件读取并写入到目标位置 + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat > $allowlistPath")) + + context.contentResolver.openInputStream(uri)?.use { input -> + input.copyTo(process.outputStream) + } + process.outputStream.close() + + process.waitFor() + + val error = BufferedReader(InputStreamReader(process.errorStream)).readText() + if (process.exitValue() != 0) { + throw IOException(context.getString(R.string.command_execution_failed, error)) + } + + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + context.getString(R.string.allowlist_restore_success), + duration = SnackbarDuration.Long + ) + } + + } catch (e: Exception) { + Log.e( + "AllowlistRestore", + context.getString(R.string.allowlist_restore_failed, ""), + e + ) + withContext(Dispatchers.Main) { + snackBarHost.showSnackbar( + context.getString(R.string.allowlist_restore_failed, e.message), + duration = SnackbarDuration.Long + ) + } + } + } + } + + @Composable + fun rememberModuleBackupLauncher( + context: Context, + snackBarHost: SnackbarHostState, + scope: CoroutineScope = rememberCoroutineScope() + ) = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + scope.launch { + backupModules(context, snackBarHost, uri) + } + } + } + } + + @Composable + fun rememberModuleRestoreLauncher( + context: Context, + snackBarHost: SnackbarHostState, + scope: CoroutineScope = rememberCoroutineScope() + ): ActivityResultLauncher { + var showRestoreDialog by remember { mutableStateOf(false) } + var restoreConfirmResult by remember { mutableStateOf?>(null) } + + // 显示恢复确认对话框 + RestoreConfirmationDialog( + showDialog = showRestoreDialog, + onConfirm = { + showRestoreDialog = false + restoreConfirmResult?.complete(true) + }, + onDismiss = { + showRestoreDialog = false + restoreConfirmResult?.complete(false) + } + ) + + return rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + scope.launch { + val confirmResult = CompletableDeferred() + restoreConfirmResult = confirmResult + + restoreModules( + context = context, + snackBarHost = snackBarHost, + uri = uri, + showConfirmDialog = { show -> showRestoreDialog = show }, + confirmResult = confirmResult + ) + } + } + } + } + } + + @Composable + fun rememberAllowlistBackupLauncher( + context: Context, + snackBarHost: SnackbarHostState, + scope: CoroutineScope = rememberCoroutineScope() + ) = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + scope.launch { + backupAllowlist(context, snackBarHost, uri) + } + } + } + } + + @Composable + fun rememberAllowlistRestoreLauncher( + context: Context, + snackBarHost: SnackbarHostState, + scope: CoroutineScope = rememberCoroutineScope() + ): ActivityResultLauncher { + var showAllowlistRestoreDialog by remember { mutableStateOf(false) } + var allowlistRestoreConfirmResult by remember { + mutableStateOf?>( + null + ) + } + + // 显示允许列表恢复确认对话框 + AllowlistRestoreConfirmationDialog( + showDialog = showAllowlistRestoreDialog, + onConfirm = { + showAllowlistRestoreDialog = false + allowlistRestoreConfirmResult?.complete(true) + }, + onDismiss = { + showAllowlistRestoreDialog = false + allowlistRestoreConfirmResult?.complete(false) + } + ) + + return rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + scope.launch { + val confirmResult = CompletableDeferred() + allowlistRestoreConfirmResult = confirmResult + + restoreAllowlist( + context = context, + snackBarHost = snackBarHost, + uri = uri, + showConfirmDialog = { show -> showAllowlistRestoreDialog = show }, + confirmResult = confirmResult + ) + } + } + } + } + } + + fun createBackupIntent(): Intent { + return Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/zip" + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + putExtra(Intent.EXTRA_TITLE, "modules_backup_$timestamp.zip") + } + } + + fun createRestoreIntent(): Intent { + return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/zip" + } + } + + fun createAllowlistBackupIntent(): Intent { + return Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + putExtra(Intent.EXTRA_TITLE, "ksu_allowlist_backup_$timestamp.dat") + } + } + + fun createAllowlistRestoreIntent(): Intent { + return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleUtils.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleUtils.kt new file mode 100644 index 0000000..5113b4a --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleUtils.kt @@ -0,0 +1,139 @@ +package com.sukisu.ultra.ui.util.module + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import com.sukisu.ultra.R +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.util.zip.ZipInputStream + +object ModuleUtils { + private const val TAG = "ModuleUtils" + + fun extractModuleName(context: Context, uri: Uri): String { + if (uri == Uri.EMPTY) { + Log.e(TAG, "The supplied URI is empty") + return context.getString(R.string.unknown_module) + } + + return try { + Log.d(TAG, "Start extracting module names from URIs: $uri") + + // 从URI路径中提取文件名 + val fileName = uri.lastPathSegment?.let { path -> + val lastSlash = path.lastIndexOf('/') + if (lastSlash != -1 && lastSlash < path.length - 1) { + path.substring(lastSlash + 1) + } else { + path + } + }?.removeSuffix(".zip") ?: context.getString(R.string.unknown_module) + + val formattedFileName = fileName.replace(Regex("[^a-zA-Z0-9\\s\\-_.@()\\u4e00-\\u9fa5]"), "").trim() + var moduleName = formattedFileName + + try { + // 打开ZIP文件输入流 + val inputStream = context.contentResolver.openInputStream(uri) + if (inputStream == null) { + Log.e(TAG, "Unable to get input stream from URI: $uri") + return formattedFileName + } + + val zipInputStream = ZipInputStream(inputStream) + var entry = zipInputStream.nextEntry + + // 遍历ZIP文件中的条目,查找module.prop文件 + while (entry != null) { + if (entry.name == "module.prop") { + val reader = BufferedReader(InputStreamReader(zipInputStream, StandardCharsets.UTF_8)) + var line: String? + while (reader.readLine().also { line = it } != null) { + if (line?.startsWith("name=") == true) { + moduleName = line.substringAfter("=") + moduleName = moduleName.replace(Regex("[^a-zA-Z0-9\\s\\-_.@()\\u4e00-\\u9fa5]"), "").trim() + break + } + } + break + } + entry = zipInputStream.nextEntry + } + zipInputStream.close() + Log.d(TAG, "Successfully extracted module name: $moduleName") + moduleName + } catch (e: IOException) { + Log.e(TAG, "Error reading ZIP file: ${e.message}") + formattedFileName + } + } catch (e: Exception) { + Log.e(TAG, "Exception when extracting module name: ${e.message}") + context.getString(R.string.unknown_module) + } + } + + // 验证URI是否有效并可访问 + fun isUriAccessible(context: Context, uri: Uri): Boolean { + if (uri == Uri.EMPTY) return false + + return try { + val inputStream = context.contentResolver.openInputStream(uri) + inputStream?.close() + inputStream != null + } catch (e: Exception) { + Log.e(TAG, "The URI is inaccessible: $uri, Error: ${e.message}") + false + } + } + + // 获取URI的持久权限 + fun takePersistableUriPermission(context: Context, uri: Uri) { + try { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + Log.d(TAG, "Persistent permissions for URIs have been obtained: $uri") + } catch (e: Exception) { + Log.e(TAG, "Unable to get persistent permissions on URIs: $uri, Error: ${e.message}") + } + } + + fun extractModuleId(context: Context, uri: Uri): String? { + if (uri == Uri.EMPTY) { + return null + } + + return try { + + val inputStream = context.contentResolver.openInputStream(uri) ?: return null + + val zipInputStream = ZipInputStream(inputStream) + var entry = zipInputStream.nextEntry + var moduleId: String? = null + + // 遍历ZIP文件中的条目,查找module.prop文件 + while (entry != null) { + if (entry.name == "module.prop") { + val reader = BufferedReader(InputStreamReader(zipInputStream, StandardCharsets.UTF_8)) + var line: String? + while (reader.readLine().also { line = it } != null) { + if (line?.startsWith("id=") == true) { + moduleId = line.substringAfter("=").trim() + break + } + } + break + } + entry = zipInputStream.nextEntry + } + zipInputStream.close() + moduleId + } catch (e: Exception) { + Log.e(TAG, "提取模块ID时发生异常: ${e.message}", e) + null + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleVerificationManager.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleVerificationManager.kt new file mode 100644 index 0000000..4194829 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/module/ModuleVerificationManager.kt @@ -0,0 +1,233 @@ +package com.sukisu.ultra.ui.util.module + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ui.util.getRootShell +import java.io.File +import java.io.FileOutputStream + +/** + * @author ShirkNeko + * @date 2025/8/3 + */ + +// 模块签名验证工具类 +object ModuleSignatureUtils { + private const val TAG = "ModuleSignatureUtils" + + fun verifyModuleSignature(context: Context, moduleUri: Uri): Boolean { + return try { + // 创建临时文件 + val tempFile = File(context.cacheDir, "temp_module_${System.currentTimeMillis()}.zip") + + // 复制URI内容到临时文件 + context.contentResolver.openInputStream(moduleUri)?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + // 调用native方法验证签名 + val isVerified = Natives.verifyModuleSignature(tempFile.absolutePath) + + // 清理临时文件 + tempFile.delete() + + Log.d(TAG, "Module signature verification result: $isVerified") + isVerified + } catch (e: Exception) { + Log.e(TAG, "Error verifying module signature", e) + false + } + } + +} + +// 验证模块签名 +fun verifyModuleSignature(context: Context, moduleUri: Uri): Boolean { + return ModuleSignatureUtils.verifyModuleSignature(context, moduleUri) +} + +object ModuleOperationUtils { + private const val TAG = "ModuleOperationUtils" + + fun handleModuleInstallSuccess(context: Context, moduleUri: Uri, isSignatureVerified: Boolean) { + if (!isSignatureVerified) { + Log.d(TAG, "模块签名未验证,跳过创建验证标志") + return + } + + try { + // 从ZIP文件提取模块ID + val moduleId = ModuleUtils.extractModuleId(context, moduleUri) + if (moduleId == null) { + Log.e(TAG, "无法提取模块ID,无法创建验证标志") + return + } + + // 创建验证标志文件 + val success = ModuleVerificationManager.createVerificationFlag(moduleId) + if (success) { + Log.d(TAG, "模块 $moduleId 验证标志创建成功") + } else { + Log.e(TAG, "模块 $moduleId 验证标志创建失败") + } + } catch (e: Exception) { + Log.e(TAG, "处理模块安装成功时发生异常", e) + } + } + + fun handleModuleUninstall(moduleId: String) { + try { + val success = ModuleVerificationManager.removeVerificationFlag(moduleId) + if (success) { + Log.d(TAG, "模块 $moduleId 验证标志移除成功") + } else { + Log.d(TAG, "模块 $moduleId 验证标志移除失败或不存在") + } + } catch (e: Exception) { + Log.e(TAG, "处理模块卸载时发生异常: $moduleId", e) + } + } + fun handleModuleUpdate(context: Context, moduleUri: Uri, isSignatureVerified: Boolean) { + try { + val moduleId = ModuleUtils.extractModuleId(context, moduleUri) + if (moduleId == null) { + Log.e(TAG, "无法提取模块ID,无法处理验证标志") + return + } + + if (isSignatureVerified) { + // 签名验证通过,创建或更新验证标志 + val success = ModuleVerificationManager.createVerificationFlag(moduleId) + if (success) { + Log.d(TAG, "模块 $moduleId 更新后验证标志已更新") + } else { + Log.e(TAG, "模块 $moduleId 更新后验证标志更新失败") + } + } else { + // 签名验证失败,移除验证标志 + ModuleVerificationManager.removeVerificationFlag(moduleId) + Log.d(TAG, "模块 $moduleId 更新后签名未验证,验证标志已移除") + } + } catch (e: Exception) { + Log.e(TAG, "处理模块更新时发生异常", e) + } + } +} + +object ModuleVerificationManager { + private const val TAG = "ModuleVerificationManager" + private const val VERIFICATION_FLAGS_DIR = "/data/adb/ksu/verified_modules" + + // 为指定模块创建验证标志文件 + fun createVerificationFlag(moduleId: String): Boolean { + return try { + val shell = getRootShell() + val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId" + + // 确保目录存在 + val createDirCommand = "mkdir -p '$VERIFICATION_FLAGS_DIR'" + shell.newJob().add(createDirCommand).exec() + + // 创建验证标志文件,写入验证时间戳 + val timestamp = System.currentTimeMillis() + val command = "echo '$timestamp' > '$flagFilePath'" + + val result = shell.newJob().add(command).exec() + + if (result.isSuccess) { + Log.d(TAG, "验证标志文件创建成功: $flagFilePath") + true + } else { + Log.e(TAG, "验证标志文件创建失败: $moduleId") + false + } + } catch (e: Exception) { + Log.e(TAG, "创建验证标志文件时发生异常: $moduleId", e) + false + } + } + + fun removeVerificationFlag(moduleId: String): Boolean { + return try { + val shell = getRootShell() + val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId" + + val command = "rm -f '$flagFilePath'" + val result = shell.newJob().add(command).exec() + + if (result.isSuccess) { + Log.d(TAG, "验证标志文件移除成功: $flagFilePath") + true + } else { + Log.e(TAG, "验证标志文件移除失败: $moduleId") + false + } + } catch (e: Exception) { + Log.e(TAG, "移除验证标志文件时发生异常: $moduleId", e) + false + } + } + + fun getVerificationTimestamp(moduleId: String): Long { + return try { + val shell = getRootShell() + val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId" + + val command = "cat '$flagFilePath' 2>/dev/null || echo '0'" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + + if (result.isSuccess && result.out.isNotEmpty()) { + val timestampStr = result.out.firstOrNull()?.trim() ?: "0" + timestampStr.toLongOrNull() ?: 0L + } else { + 0L + } + } catch (e: Exception) { + Log.e(TAG, "获取验证时间戳时发生异常: $moduleId", e) + 0L + } + } + + fun batchCheckVerificationStatus(moduleIds: List): Map { + if (moduleIds.isEmpty()) return emptyMap() + + return try { + val shell = getRootShell() + val result = mutableMapOf() + + // 确保目录存在 + val createDirCommand = "mkdir -p '$VERIFICATION_FLAGS_DIR'" + shell.newJob().add(createDirCommand).exec() + + // 批量检查所有模块的验证标志文件 + val commands = moduleIds.map { moduleId -> + "test -f '$VERIFICATION_FLAGS_DIR/$moduleId' && echo '$moduleId:true' || echo '$moduleId:false'" + } + + val command = commands.joinToString(" && ") + val shellResult = shell.newJob().add(command).to(ArrayList(), null).exec() + + if (shellResult.isSuccess) { + shellResult.out.forEach { line -> + val parts = line.split(":") + if (parts.size == 2) { + val moduleId = parts[0] + val isVerified = parts[1] == "true" + result[moduleId] = isVerified + } + } + } + + Log.d(TAG, "批量验证检查完成,共检查 ${moduleIds.size} 个模块") + result + } catch (e: Exception) { + Log.e(TAG, "批量检查验证状态时发生异常", e) + // 返回默认值,所有模块都标记为未验证 + moduleIds.associateWith { false } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/HomeViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..72e069f --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/HomeViewModel.kt @@ -0,0 +1,590 @@ +package com.sukisu.ultra.ui.viewmodel + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.system.Os +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sukisu.ultra.KernelVersion +import com.sukisu.ultra.Natives +import com.sukisu.ultra.getKernelVersion +import com.sukisu.ultra.ksuApp +import com.sukisu.ultra.ui.util.* +import com.sukisu.ultra.ui.util.module.LatestVersionInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class HomeViewModel : ViewModel() { + + // 系统状态 + data class SystemStatus( + val isManager: Boolean = false, + val ksuVersion: Int? = null, + val ksuFullVersion : String? = null, + val lkmMode: Boolean? = null, + val kernelVersion: KernelVersion = getKernelVersion(), + val isRootAvailable: Boolean = false, + val isKpmConfigured: Boolean = false, + val requireNewKernel: Boolean = false + ) + + // 系统信息 + data class SystemInfo( + val kernelRelease: String = "", + val androidVersion: String = "", + val deviceModel: String = "", + val managerVersion: Pair = Pair("", 0L), + val seLinuxStatus: String = "", + val kpmVersion: String = "", + val suSFSStatus: String = "", + val suSFSVersion: String = "", + val suSFSVariant: String = "", + val suSFSFeatures: String = "", + val superuserCount: Int = 0, + val moduleCount: Int = 0, + val kpmModuleCount: Int = 0, + val managersList: Natives.ManagersList? = null, + val isDynamicSignEnabled: Boolean = false, + val zygiskImplement: String = "" + ) + + // 状态变量 + var systemStatus by mutableStateOf(SystemStatus()) + private set + + var systemInfo by mutableStateOf(SystemInfo()) + private set + + var latestVersionInfo by mutableStateOf(LatestVersionInfo()) + private set + + var isSimpleMode by mutableStateOf(false) + private set + var isKernelSimpleMode by mutableStateOf(false) + private set + var isHideVersion by mutableStateOf(false) + private set + var isHideOtherInfo by mutableStateOf(false) + private set + var isHideSusfsStatus by mutableStateOf(false) + private set + var isHideZygiskImplement by mutableStateOf(false) + private set + var isHideLinkCard by mutableStateOf(false) + private set + var showKpmInfo by mutableStateOf(false) + private set + + var isCoreDataLoaded by mutableStateOf(false) + private set + var isExtendedDataLoaded by mutableStateOf(false) + private set + var isRefreshing by mutableStateOf(false) + private set + + // 数据刷新状态流,用于监听变化 + private val _dataRefreshTrigger = MutableStateFlow(0L) + val dataRefreshTrigger: StateFlow = _dataRefreshTrigger + + private var loadingJobs = mutableListOf() + private var lastRefreshTime = 0L + private val refreshCooldown = 2000L + + fun loadUserSettings(context: Context) { + viewModelScope.launch(Dispatchers.IO) { + val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + isSimpleMode = settingsPrefs.getBoolean("is_simple_mode", false) + isKernelSimpleMode = settingsPrefs.getBoolean("is_kernel_simple_mode", false) + isHideVersion = settingsPrefs.getBoolean("is_hide_version", false) + isHideOtherInfo = settingsPrefs.getBoolean("is_hide_other_info", false) + isHideSusfsStatus = settingsPrefs.getBoolean("is_hide_susfs_status", false) + isHideLinkCard = settingsPrefs.getBoolean("is_hide_link_card", false) + isHideZygiskImplement = settingsPrefs.getBoolean("is_hide_zygisk_Implement", false) + showKpmInfo = settingsPrefs.getBoolean("show_kpm_info", false) + } + } + + fun loadCoreData() { + if (isCoreDataLoaded) return + + val job = viewModelScope.launch(Dispatchers.IO) { + try { + val kernelVersion = getKernelVersion() + val isManager = try { + Natives.isManager + } catch (_: Exception) { + false + } + + val ksuVersion = if (isManager) Natives.version else null + + val fullVersion = try { + Natives.getFullVersion() + } catch (_: Exception) { + "Unknown" + } + + val ksuFullVersion = if (isKernelSimpleMode) { + try { + val startIndex = fullVersion.indexOf('v') + if (startIndex >= 0) { + val endIndex = fullVersion.indexOf('-', startIndex) + val versionStr = if (endIndex > startIndex) { + fullVersion.substring(startIndex, endIndex) + } else { + fullVersion.substring(startIndex) + } + val numericVersion = "v" + (Regex("""\d+(\.\d+)*""").find(versionStr)?.value ?: versionStr) + numericVersion + } else { + fullVersion + } + } catch (_: Exception) { + fullVersion + } + } else { + fullVersion + } + + val lkmMode = ksuVersion?.let { + if (kernelVersion.isGKI()) Natives.isLkmMode else null + } + + val isRootAvailable = try { + rootAvailable() + } catch (_: Exception) { + false + } + + val isKpmConfigured = try { + Natives.isKPMEnabled() + } catch (_: Exception) { + false + } + + val requireNewKernel = try { + isManager && Natives.requireNewKernel() + } catch (_: Exception) { + false + } + + systemStatus = SystemStatus( + isManager = isManager, + ksuVersion = ksuVersion, + ksuFullVersion = ksuFullVersion, + lkmMode = lkmMode, + kernelVersion = kernelVersion, + isRootAvailable = isRootAvailable, + isKpmConfigured = isKpmConfigured, + requireNewKernel = requireNewKernel + ) + + isCoreDataLoaded = true + } catch (_: Exception) { + } + } + loadingJobs.add(job) + } + + fun loadExtendedData(context: Context) { + if (isExtendedDataLoaded) return + + val job = viewModelScope.launch(Dispatchers.IO) { + try { + // 分批加载 + delay(50) + + val basicInfo = loadBasicSystemInfo(context) + systemInfo = systemInfo.copy( + kernelRelease = basicInfo.first, + androidVersion = basicInfo.second, + deviceModel = basicInfo.third, + managerVersion = basicInfo.fourth, + seLinuxStatus = basicInfo.fifth + ) + + delay(100) + + // 加载模块信息 + if (!isSimpleMode) { + val moduleInfo = loadModuleInfo() + systemInfo = systemInfo.copy( + kpmVersion = moduleInfo.first, + superuserCount = moduleInfo.second, + moduleCount = moduleInfo.third, + kpmModuleCount = moduleInfo.fourth, + zygiskImplement = moduleInfo.fifth + ) + } + + delay(100) + + // 加载SuSFS信息 + if (!isHideSusfsStatus) { + val suSFSInfo = loadSuSFSInfo() + systemInfo = systemInfo.copy( + suSFSStatus = suSFSInfo.first, + suSFSVersion = suSFSInfo.second, + suSFSVariant = suSFSInfo.third, + suSFSFeatures = suSFSInfo.fourth, + ) + } + + delay(100) + + // 加载管理器列表 + val managerInfo = loadManagerInfo() + systemInfo = systemInfo.copy( + managersList = managerInfo.first, + isDynamicSignEnabled = managerInfo.second + ) + + isExtendedDataLoaded = true + } catch (_: Exception) { + // 静默处理错误 + } + } + loadingJobs.add(job) + } + + fun refreshData(context: Context, forceRefresh: Boolean = false) { + val currentTime = System.currentTimeMillis() + + // 如果不是强制刷新,检查冷却时间 + if (!forceRefresh && currentTime - lastRefreshTime < refreshCooldown) { + return + } + + lastRefreshTime = currentTime + + viewModelScope.launch { + isRefreshing = true + + try { + // 取消正在进行的加载任务 + loadingJobs.forEach { it.cancel() } + loadingJobs.clear() + + // 重置状态 + isCoreDataLoaded = false + isExtendedDataLoaded = false + + // 触发数据刷新状态流 + _dataRefreshTrigger.value = currentTime + + // 重新加载用户设置 + loadUserSettings(context) + + // 重新加载核心数据 + loadCoreData() + delay(100) + + // 重新加载扩展数据 + loadExtendedData(context) + + // 检查更新 + val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val checkUpdate = settingsPrefs.getBoolean("check_update", true) + if (checkUpdate) { + try { + val newVersionInfo = withContext(Dispatchers.IO) { + checkNewVersion() + } + latestVersionInfo = newVersionInfo + } catch (_: Exception) { + } + } + } catch (_: Exception) { + // 静默处理错误 + } finally { + isRefreshing = false + } + } + } + + // 手动触发刷新(下拉刷新使用) + fun onPullRefresh(context: Context) { + refreshData(context, forceRefresh = true) + } + + // 自动刷新数据(当检测到变化时) + fun autoRefreshIfNeeded(context: Context) { + viewModelScope.launch { + // 检查是否需要刷新数据 + val needsRefresh = checkIfDataNeedsRefresh() + if (needsRefresh) { + refreshData(context) + } + } + } + + private suspend fun checkIfDataNeedsRefresh(): Boolean { + return withContext(Dispatchers.IO) { + try { + // 检查KSU状态是否发生变化 + val currentKsuVersion = try { + if (Natives.isManager) { + Natives.version + } else null + } catch (_: Exception) { + null + } + + // 如果KSU版本发生变化,需要刷新 + if (currentKsuVersion != systemStatus.ksuVersion) { + return@withContext true + } + + // 检查模块数量是否发生变化 + val currentModuleCount = try { + getModuleCount() + } catch (_: Exception) { + systemInfo.moduleCount + } + + if (currentModuleCount != systemInfo.moduleCount) { + return@withContext true + } + + false + } catch (_: Exception) { + false + } + } + } + + private suspend fun loadBasicSystemInfo(context: Context): Tuple5, String> { + return withContext(Dispatchers.IO) { + val uname = try { + Os.uname() + } catch (_: Exception) { + null + } + + val deviceModel = try { + getDeviceModel() + } catch (_: Exception) { + "Unknown" + } + + val managerVersion = try { + getManagerVersion(context) + } catch (_: Exception) { + Pair("Unknown", 0L) + } + + val seLinuxStatus = try { + getSELinuxStatus(ksuApp.applicationContext) + } catch (_: Exception) { + "Unknown" + } + + Tuple5( + uname?.release ?: "Unknown", + Build.VERSION.RELEASE ?: "Unknown", + deviceModel, + managerVersion, + seLinuxStatus + ) + } + } + + private suspend fun loadModuleInfo(): Tuple5 { + return withContext(Dispatchers.IO) { + val kpmVersion = try { + getKpmVersion() + } catch (_: Exception) { + "Unknown" + } + + val superuserCount = try { + getSuperuserCount() + } catch (_: Exception) { + 0 + } + + val moduleCount = try { + getModuleCount() + } catch (_: Exception) { + 0 + } + + val kpmModuleCount = try { + getKpmModuleCount() + } catch (_: Exception) { + 0 + } + + val zygiskImplement = try { + getZygiskImplement() + } catch (_: Exception) { + "None" + } + + Tuple5(kpmVersion, superuserCount, moduleCount, kpmModuleCount, zygiskImplement) + } + } + + private suspend fun loadSuSFSInfo(): Tuple4 { + return withContext(Dispatchers.IO) { + val suSFS = try { + val rawFeature = getSuSFSFeatures() + if (rawFeature.isNotEmpty() && !rawFeature.startsWith("[-]")) { + "Supported" + } else { + rawFeature + } + } catch (_: Exception) { + "Unknown" + } + + if (suSFS != "Supported") { + return@withContext Tuple4(suSFS, "", "", "") + } + + val suSFSVersion = try { + getSuSFSVersion() + } catch (_: Exception) { + "" + } + + if (suSFSVersion.isEmpty()) { + return@withContext Tuple4(suSFS, "", "", "") + } + + val suSFSVariant = try { + getSuSFSVariant() + } catch (_: Exception) { + "" + } + + val suSFSFeatures = try { + getSuSFSFeatures() + } catch (_: Exception) { + "" + } + + Tuple4(suSFS, suSFSVersion, suSFSVariant, suSFSFeatures) + } + } + + private suspend fun loadManagerInfo(): Pair { + return withContext(Dispatchers.IO) { + val dynamicSignConfig = try { + Natives.getDynamicManager() + } catch (_: Exception) { + null + } + + val isDynamicSignEnabled = try { + dynamicSignConfig?.isValid() == true + } catch (_: Exception) { + false + } + + val managersList = if (isDynamicSignEnabled) { + try { + Natives.getManagersList() + } catch (_: Exception) { + null + } + } else { + null + } + + Pair(managersList, isDynamicSignEnabled) + } + } + + @SuppressLint("PrivateApi") + private fun getDeviceModel(): String { + return try { + val systemProperties = Class.forName("android.os.SystemProperties") + val getMethod = systemProperties.getMethod("get", String::class.java, String::class.java) + val marketNameKeys = listOf( + "ro.product.marketname", + "ro.vendor.oplus.market.name", + "ro.vivo.market.name", + "ro.config.marketing_name" + ) + var result = getDeviceInfo() + for (key in marketNameKeys) { + try { + val marketName = getMethod.invoke(null, key, "") as String + if (marketName.isNotEmpty()) { + result = marketName + break + } + } catch (_: Exception) { + } + } + result + } catch ( + + _: Exception) { + getDeviceInfo() + } + } + + private fun getDeviceInfo(): String { + return try { + var manufacturer = Build.MANUFACTURER ?: "Unknown" + manufacturer = manufacturer[0].uppercaseChar().toString() + manufacturer.substring(1) + + val brand = Build.BRAND ?: "" + if (brand.isNotEmpty() && !brand.equals(Build.MANUFACTURER, ignoreCase = true)) { + manufacturer += " " + brand[0].uppercaseChar() + brand.substring(1) + } + + val model = Build.MODEL ?: "" + if (model.isNotEmpty()) { + manufacturer += " $model " + } + + manufacturer + } catch (_: Exception) { + "Unknown Device" + } + } + + private fun getManagerVersion(context: Context): Pair { + return try { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val versionCode = androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(packageInfo) + val versionName = packageInfo.versionName ?: "Unknown" + Pair(versionName, versionCode) + } catch (_: Exception) { + Pair("Unknown", 0L) + } + } + + data class Tuple5( + val first: T1, + val second: T2, + val third: T3, + val fourth: T4, + val fifth: T5 + ) + + data class Tuple4( + val first: T1, + val second: T2, + val third: T3, + val fourth: T4 + ) + + override fun onCleared() { + super.onCleared() + loadingJobs.forEach { it.cancel() } + loadingJobs.clear() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/KpmViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/KpmViewModel.kt new file mode 100644 index 0000000..36c5b43 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/KpmViewModel.kt @@ -0,0 +1,160 @@ +package com.sukisu.ultra.ui.viewmodel + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +class KpmViewModel : ViewModel() { + var moduleList by mutableStateOf(emptyList()) + private set + + var search by mutableStateOf("") + internal set + + var isRefreshing by mutableStateOf(false) + private set + + var currentModuleDetail by mutableStateOf("") + private set + + fun fetchModuleList() { + viewModelScope.launch { + isRefreshing = true + try { + val moduleCount = getKpmModuleCount() + Log.d("KsuCli", "Module count: $moduleCount") + + moduleList = getAllKpmModuleInfo() + + // 获取 KPM 版本信息 + val kpmVersion = getKpmVersion() + Log.d("KsuCli", "KPM Version: $kpmVersion") + } catch (e: Exception) { + Log.e("KsuCli", "获取模块列表失败", e) + } finally { + isRefreshing = false + } + } + } + + private fun getAllKpmModuleInfo(): List { + val result = mutableListOf() + try { + val str = listKpmModules() + val moduleNames = str + .split("\n") + .filter { it.isNotBlank() } + + for (name in moduleNames) { + try { + val moduleInfo = parseModuleInfo(name) + moduleInfo?.let { result.add(it) } + } catch (e: Exception) { + Log.e("KsuCli", "Error processing module $name", e) + } + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to get module list", e) + } + return result + } + + private fun parseModuleInfo(name: String): ModuleInfo? { + val info = getKpmModuleInfo(name) + if (info.isBlank()) return null + + val properties = info.lineSequence() + .filter { line -> + val trimmed = line.trim() + trimmed.isNotEmpty() && !trimmed.startsWith("#") + } + .mapNotNull { line -> + line.split("=", limit = 2).let { parts -> + when (parts.size) { + 2 -> parts[0].trim() to parts[1].trim() + 1 -> parts[0].trim() to "" + else -> null + } + } + } + .toMap() + + return ModuleInfo( + id = name, + name = properties["name"] ?: name, + version = properties["version"] ?: "", + author = properties["author"] ?: "", + description = properties["description"] ?: "", + args = properties["args"] ?: "", + enabled = true, + hasAction = true + ) + } + + fun loadModuleDetail(moduleId: String) { + viewModelScope.launch { + try { + currentModuleDetail = withContext(Dispatchers.IO) { + getKpmModuleInfo(moduleId) + } + Log.d("KsuCli", "Module detail loaded: $currentModuleDetail") + } catch (e: Exception) { + Log.e("KsuCli", "Failed to load module detail", e) + currentModuleDetail = "Error: ${e.message}" + } + } + } + + var showInputDialog by mutableStateOf(false) + private set + + var selectedModuleId by mutableStateOf(null) + private set + + var inputArgs by mutableStateOf("") + private set + + fun showInputDialog(moduleId: String) { + selectedModuleId = moduleId + showInputDialog = true + } + + fun hideInputDialog() { + showInputDialog = false + selectedModuleId = null + inputArgs = "" + } + + fun updateInputArgs(args: String) { + inputArgs = args + } + + fun executeControl(): Int { + val moduleId = selectedModuleId ?: return -1 + val result = controlKpmModule(moduleId, inputArgs) + hideInputDialog() + return result + } + + data class ModuleInfo( + val id: String, + val name: String, + val version: String, + val author: String, + val description: String, + val args: String, + val enabled: Boolean, + val hasAction: Boolean + ) +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt new file mode 100644 index 0000000..7a5fd9b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt @@ -0,0 +1,504 @@ +package com.sukisu.ultra.ui.viewmodel + +import android.content.Context +import android.os.SystemClock +import android.util.Log +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dergoogler.mmrl.platform.model.ModuleConfig +import com.dergoogler.mmrl.platform.model.ModuleConfig.Companion.asModuleConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.sukisu.ultra.ui.util.HanziToPinyin +import com.sukisu.ultra.ui.util.listModules +import com.sukisu.ultra.ui.util.getRootShell +import com.sukisu.ultra.ui.util.module.ModuleVerificationManager +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.text.Collator +import java.text.DecimalFormat +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.log10 +import kotlin.math.pow +import androidx.core.content.edit + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +class ModuleViewModel : ViewModel() { + + companion object { + private const val TAG = "ModuleViewModel" + private var modules by mutableStateOf>(emptyList()) + private const val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0" + } + + // 模块大小缓存管理器 + private lateinit var moduleSizeCache: ModuleSizeCache + + fun initializeCache(context: Context) { + if (!::moduleSizeCache.isInitialized) { + moduleSizeCache = ModuleSizeCache(context) + } + } + + fun getModuleSize(dirId: String): String { + if (!::moduleSizeCache.isInitialized) { + return "0 KB" + } + val size = moduleSizeCache.getModuleSize(dirId) + return formatFileSize(size) + } + + /** + * 刷新所有模块的大小缓存 + * 只在安装、卸载、更新模块后调用 + */ + fun refreshModuleSizeCache() { + if (!::moduleSizeCache.isInitialized) return + + viewModelScope.launch(Dispatchers.IO) { + Log.d(TAG, "开始刷新模块大小缓存") + val currentModules = modules.map { it.dirId } + moduleSizeCache.refreshCache(currentModules) + Log.d(TAG, "模块大小缓存刷新完成") + } + } + + class ModuleInfo( + val id: String, + val name: String, + val author: String, + val version: String, + val versionCode: Int, + val description: String, + val enabled: Boolean, + val update: Boolean, + val remove: Boolean, + val updateJson: String, + val hasWebUi: Boolean, + val hasActionScript: Boolean, + val dirId: String, // real module id (dir name) + var config: ModuleConfig? = null, + var isVerified: Boolean = false, // 添加验证状态字段 + var verificationTimestamp: Long = 0L, // 添加验证时间戳 + ) + + var isRefreshing by mutableStateOf(false) + private set + var search by mutableStateOf("") + + var sortEnabledFirst by mutableStateOf(false) + var sortActionFirst by mutableStateOf(false) + val moduleList by derivedStateOf { + val comparator = + compareBy( + { if (sortEnabledFirst) !it.enabled else 0 }, + { if (sortActionFirst) !it.hasWebUi && !it.hasActionScript else 0 }, + ).thenBy(Collator.getInstance(Locale.getDefault()), ModuleInfo::id) + modules.filter { + it.id.contains(search, true) || it.name.contains(search, true) || HanziToPinyin.getInstance() + .toPinyinString(it.name)?.contains(search, true) == true + }.sortedWith(comparator).also { + isRefreshing = false + } + } + + var isNeedRefresh by mutableStateOf(false) + private set + + fun markNeedRefresh() { + isNeedRefresh = true + // 标记需要刷新时,同时刷新大小缓存 + refreshModuleSizeCache() + } + + fun fetchModuleList() { + viewModelScope.launch(Dispatchers.IO) { + isRefreshing = true + + val oldModuleList = modules + + val start = SystemClock.elapsedRealtime() + + kotlin.runCatching { + val result = listModules() + + Log.i(TAG, "result: $result") + + val array = JSONArray(result) + val moduleInfos = (0 until array.length()) + .asSequence() + .map { array.getJSONObject(it) } + .map { obj -> + ModuleInfo( + obj.getString("id"), + obj.optString("name"), + obj.optString("author", "Unknown"), + obj.optString("version", "Unknown"), + obj.getIntCompat("versionCode", 0), + obj.optString("description"), + obj.getBooleanCompat("enabled"), + obj.getBooleanCompat("update"), + obj.getBooleanCompat("remove"), + obj.optString("updateJson"), + obj.getBooleanCompat("web"), + obj.getBooleanCompat("action"), + obj.optString("dir_id", obj.getString("id")) + ) + }.toList() + + // 批量检查所有模块的验证状态 + val moduleIds = moduleInfos.map { it.dirId } + val verificationStatus = ModuleVerificationManager.batchCheckVerificationStatus(moduleIds) + + // 更新模块验证状态 + modules = moduleInfos.map { moduleInfo -> + val isVerified = verificationStatus[moduleInfo.dirId] ?: false + val verificationTimestamp = if (isVerified) { + ModuleVerificationManager.getVerificationTimestamp(moduleInfo.dirId) + } else { + 0L + } + + moduleInfo.copy( + isVerified = isVerified, + verificationTimestamp = verificationTimestamp + ) + } + + launch { + modules.forEach { module -> + withContext(Dispatchers.IO) { + try { + runCatching { + module.config = module.id.asModuleConfig + }.onFailure { e -> + Log.e(TAG, "Failed to load config from id for module ${module.id}", e) + } + if (module.config == null) { + runCatching { + module.config = module.name.asModuleConfig + }.onFailure { e -> + Log.e(TAG, "Failed to load config from name for module ${module.id}", e) + } + } + if (module.config == null) { + runCatching { + module.config = module.description.asModuleConfig + }.onFailure { e -> + Log.e(TAG, "Failed to load config from description for module ${module.id}", e) + } + } + if (module.config == null) { + module.config = ModuleConfig() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load any config for module ${module.id}", e) + module.config = ModuleConfig() + } + } + } + } + + // 首次加载模块列表时,初始化缓存 + if (::moduleSizeCache.isInitialized) { + val currentModules = modules.map { it.dirId } + moduleSizeCache.initializeCacheIfNeeded(currentModules) + } + + isNeedRefresh = false + }.onFailure { e -> + Log.e(TAG, "fetchModuleList: ", e) + isRefreshing = false + } + + // when both old and new is kotlin.collections.EmptyList + // moduleList update will don't trigger + if (oldModuleList === modules) { + isRefreshing = false + } + + Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules") + } + } + + private fun sanitizeVersionString(version: String): String { + return version.replace(Regex("[^a-zA-Z0-9.\\-_]"), "_") + } + + fun checkUpdate(m: ModuleInfo): Triple { + val empty = Triple("", "", "") + if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) { + return empty + } + // download updateJson + val result = kotlin.runCatching { + val url = m.updateJson + Log.i(TAG, "checkUpdate url: $url") + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", CUSTOM_USER_AGENT) + .build() + + val response = client.newCall(request).execute() + + Log.d(TAG, "checkUpdate code: ${response.code}") + if (response.isSuccessful) { + response.body?.string() ?: "" + } else { + Log.d(TAG, "checkUpdate failed: ${response.message}") + "" + } + }.getOrElse { e -> + Log.e(TAG, "checkUpdate exception", e) + "" + } + + Log.i(TAG, "checkUpdate result: $result") + + if (result.isEmpty()) { + return empty + } + + val updateJson = kotlin.runCatching { + JSONObject(result) + }.getOrNull() ?: return empty + + var version = updateJson.optString("version", "") + version = sanitizeVersionString(version) + val versionCode = updateJson.optInt("versionCode", 0) + val zipUrl = updateJson.optString("zipUrl", "") + val changelog = updateJson.optString("changelog", "") + if (versionCode <= m.versionCode || zipUrl.isEmpty()) { + return empty + } + + return Triple(zipUrl, version, changelog) + } +} + +fun ModuleViewModel.ModuleInfo.copy( + id: String = this.id, + name: String = this.name, + author: String = this.author, + version: String = this.version, + versionCode: Int = this.versionCode, + description: String = this.description, + enabled: Boolean = this.enabled, + update: Boolean = this.update, + remove: Boolean = this.remove, + updateJson: String = this.updateJson, + hasWebUi: Boolean = this.hasWebUi, + hasActionScript: Boolean = this.hasActionScript, + dirId: String = this.dirId, + config: ModuleConfig? = this.config, + isVerified: Boolean = this.isVerified, + verificationTimestamp: Long = this.verificationTimestamp +): ModuleViewModel.ModuleInfo { + return ModuleViewModel.ModuleInfo( + id, name, author, version, versionCode, description, + enabled, update, remove, updateJson, hasWebUi, hasActionScript, + dirId, config, isVerified, verificationTimestamp + ) +} + +/** + * 模块大小缓存管理器 + */ +class ModuleSizeCache(context: Context) { + companion object { + private const val TAG = "ModuleSizeCache" + private const val CACHE_PREFS_NAME = "module_size_cache" + private const val CACHE_VERSION_KEY = "cache_version" + private const val CACHE_INITIALIZED_KEY = "cache_initialized" + private const val CURRENT_CACHE_VERSION = 1 + } + + private val cachePrefs = context.getSharedPreferences(CACHE_PREFS_NAME, Context.MODE_PRIVATE) + private val sizeCache = mutableMapOf() + + init { + loadCacheFromPrefs() + } + + /** + * 从SharedPreferences加载缓存 + */ + private fun loadCacheFromPrefs() { + try { + val cacheVersion = cachePrefs.getInt(CACHE_VERSION_KEY, 0) + if (cacheVersion != CURRENT_CACHE_VERSION) { + Log.d(TAG, "缓存版本不匹配,清空缓存") + clearCache() + return + } + + val allEntries = cachePrefs.all + for ((key, value) in allEntries) { + if (key != CACHE_VERSION_KEY && key != CACHE_INITIALIZED_KEY && value is Long) { + sizeCache[key] = value + } + } + Log.d(TAG, "从缓存加载了 ${sizeCache.size} 个模块大小数据") + } catch (e: Exception) { + Log.e(TAG, "加载缓存失败", e) + clearCache() + } + } + + /** + * 保存缓存到SharedPreferences + */ + private fun saveCacheToPrefs() { + try { + cachePrefs.edit { + putInt(CACHE_VERSION_KEY, CURRENT_CACHE_VERSION) + putBoolean(CACHE_INITIALIZED_KEY, true) + + for ((dirId, size) in sizeCache) { + putLong(dirId, size) + } + + } + Log.d(TAG, "保存了 ${sizeCache.size} 个模块大小到缓存") + } catch (e: Exception) { + Log.e(TAG, "保存缓存失败", e) + } + } + + /** + * 获取模块大小(从缓存) + */ + fun getModuleSize(dirId: String): Long { + return sizeCache[dirId] ?: 0L + } + + /** + * 检查缓存是否已初始化,如果没有则初始化 + */ + fun initializeCacheIfNeeded(currentModules: List) { + val isInitialized = cachePrefs.getBoolean(CACHE_INITIALIZED_KEY, false) + if (!isInitialized || sizeCache.isEmpty()) { + Log.d(TAG, "首次初始化缓存,计算所有模块大小") + refreshCache(currentModules) + } else { + // 检查是否有新模块需要计算大小 + val newModules = currentModules.filter { !sizeCache.containsKey(it) } + if (newModules.isNotEmpty()) { + Log.d(TAG, "发现 ${newModules.size} 个新模块,计算大小: $newModules") + for (dirId in newModules) { + val size = calculateModuleFolderSize(dirId) + sizeCache[dirId] = size + Log.d(TAG, "新模块 $dirId 大小: ${formatFileSize(size)}") + } + saveCacheToPrefs() + } + } + } + + /** + * 刷新所有模块的大小缓存 + */ + fun refreshCache(currentModules: List) { + try { + // 清理不存在的模块缓存 + val toRemove = sizeCache.keys.filter { it !in currentModules } + toRemove.forEach { sizeCache.remove(it) } + + if (toRemove.isNotEmpty()) { + Log.d(TAG, "清理了 ${toRemove.size} 个不存在的模块缓存: $toRemove") + } + + // 计算所有当前模块的大小 + for (dirId in currentModules) { + val size = calculateModuleFolderSize(dirId) + sizeCache[dirId] = size + Log.d(TAG, "更新模块 $dirId 大小: ${formatFileSize(size)}") + } + + // 保存到持久化存储 + saveCacheToPrefs() + } catch (e: Exception) { + Log.e(TAG, "刷新缓存失败", e) + } + } + + /** + * 清空所有缓存 + */ + private fun clearCache() { + sizeCache.clear() + cachePrefs.edit { clear() } + Log.d(TAG, "清空所有缓存") + } + + /** + * 实际计算模块文件夹大小 + */ + private fun calculateModuleFolderSize(dirId: String): Long { + return try { + val shell = getRootShell() + val command = "du -sb /data/adb/modules/$dirId" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + + if (result.isSuccess && result.out.isNotEmpty()) { + val sizeStr = result.out.firstOrNull()?.split("\t")?.firstOrNull() + sizeStr?.toLongOrNull() ?: 0L + } else { + 0L + } + } catch (e: Exception) { + Log.e(TAG, "计算模块大小失败 $dirId: ${e.message}") + 0L + } + } +} + +private fun JSONObject.getBooleanCompat(key: String, default: Boolean = false): Boolean { + if (!has(key)) return default + return when (val value = opt(key)) { + is Boolean -> value + is String -> value.equals("true", ignoreCase = true) || value == "1" + is Number -> value.toInt() != 0 + else -> default + } +} + +private fun JSONObject.getIntCompat(key: String, default: Int = 0): Int { + if (!has(key)) return default + return when (val value = opt(key)) { + is Int -> value + is Number -> value.toInt() + is String -> value.toIntOrNull() ?: default + else -> default + } +} + +/** + * 格式化文件大小的工具函数 + */ +fun formatFileSize(bytes: Long): String { + if (bytes <= 0) return "0 KB" + + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt() + + return DecimalFormat("#,##0.#").format( + bytes / 1024.0.pow(digitGroups.toDouble()) + ) + " " + units[digitGroups] +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt new file mode 100644 index 0000000..128c238 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt @@ -0,0 +1,401 @@ +package com.sukisu.ultra.ui.viewmodel + +import android.content.* +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.graphics.drawable.Drawable +import android.os.IBinder +import android.os.Parcelable +import android.util.Log +import androidx.compose.runtime.* +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ksuApp +import com.sukisu.ultra.ui.KsuService +import com.sukisu.ultra.ui.util.* +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.text.Collator +import java.util.* +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import com.sukisu.zako.IKsuInterface +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +enum class AppCategory(val displayNameRes: Int, val persistKey: String) { + ALL(com.sukisu.ultra.R.string.category_all_apps, "ALL"), + ROOT(com.sukisu.ultra.R.string.category_root_apps, "ROOT"), + CUSTOM(com.sukisu.ultra.R.string.category_custom_apps, "CUSTOM"), + DEFAULT(com.sukisu.ultra.R.string.category_default_apps, "DEFAULT"); + + companion object { + fun fromPersistKey(key: String): AppCategory = entries.find { it.persistKey == key } ?: ALL + } +} + +enum class SortType(val displayNameRes: Int, val persistKey: String) { + NAME_ASC(com.sukisu.ultra.R.string.sort_name_asc, "NAME_ASC"), + NAME_DESC(com.sukisu.ultra.R.string.sort_name_desc, "NAME_DESC"), + INSTALL_TIME_NEW(com.sukisu.ultra.R.string.sort_install_time_new, "INSTALL_TIME_NEW"), + INSTALL_TIME_OLD(com.sukisu.ultra.R.string.sort_install_time_old, "INSTALL_TIME_OLD"), + SIZE_DESC(com.sukisu.ultra.R.string.sort_size_desc, "SIZE_DESC"), + SIZE_ASC(com.sukisu.ultra.R.string.sort_size_asc, "SIZE_ASC"), + USAGE_FREQ(com.sukisu.ultra.R.string.sort_usage_freq, "USAGE_FREQ"); + + companion object { + fun fromPersistKey(key: String): SortType = entries.find { it.persistKey == key } ?: NAME_ASC + } +} + +class SuperUserViewModel : ViewModel() { + companion object { + private const val TAG = "SuperUserViewModel" + private val appsLock = Any() + var apps by mutableStateOf>(emptyList()) + private val _isAppListLoaded = MutableStateFlow(false) + val isAppListLoaded = _isAppListLoaded.asStateFlow() + + @JvmStatic + fun getAppIconDrawable(context: Context, packageName: String): Drawable? { + val appList = synchronized(appsLock) { apps } + return appList.find { it.packageName == packageName } + ?.packageInfo?.applicationInfo?.loadIcon(context.packageManager) + } + + var appGroups by mutableStateOf>(emptyList()) + + private const val PREFS_NAME = "settings" + private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps" + private const val KEY_SELECTED_CATEGORY = "selected_category" + private const val KEY_CURRENT_SORT_TYPE = "current_sort_type" + private const val CORE_POOL_SIZE = 8 + private const val MAX_POOL_SIZE = 16 + private const val KEEP_ALIVE_TIME = 60L + private const val BATCH_SIZE = 20 + } + + @Immutable + @Parcelize + data class AppInfo( + val label: String, + val packageInfo: PackageInfo, + val profile: Natives.Profile?, + ) : Parcelable { + @IgnoredOnParcel + val packageName: String = packageInfo.packageName + @IgnoredOnParcel + val uid: Int = packageInfo.applicationInfo!!.uid + } + + @Immutable + @Parcelize + data class AppGroup( + val uid: Int, + val apps: List, + val profile: Natives.Profile? + ) : Parcelable { + @IgnoredOnParcel + val mainApp: AppInfo = apps.first() + @IgnoredOnParcel + val packageNames: List = apps.map { it.packageName } + @IgnoredOnParcel + val allowSu: Boolean = profile?.allowSu == true + @IgnoredOnParcel + val userName: String? = Natives.getUserName(uid) + @IgnoredOnParcel + val hasCustomProfile : Boolean = profile?.let { if (it.allowSu) !it.rootUseDefault else !it.nonRootUseDefault } ?: false + } + + private val appProcessingThreadPool = ThreadPoolExecutor( + CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, + LinkedBlockingQueue() + ) { runnable -> + Thread(runnable, "AppProcessing-${System.currentTimeMillis()}").apply { + isDaemon = true + priority = Thread.NORM_PRIORITY + } + }.asCoroutineDispatcher() + + private val appListMutex = Mutex() + private val configChangeListeners = mutableSetOf<(String) -> Unit>() + private val prefs = ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + var search by mutableStateOf("") + var showSystemApps by mutableStateOf(prefs.getBoolean(KEY_SHOW_SYSTEM_APPS, false)) + private set + var selectedCategory by mutableStateOf(loadSelectedCategory()) + private set + var currentSortType by mutableStateOf(loadCurrentSortType()) + private set + var isRefreshing by mutableStateOf(false) + private set + var showBatchActions by mutableStateOf(false) + internal set + var selectedApps by mutableStateOf>(emptySet()) + internal set + var loadingProgress by mutableFloatStateOf(0f) + private set + + private fun loadSelectedCategory(): AppCategory { + val categoryKey = prefs.getString(KEY_SELECTED_CATEGORY, AppCategory.ALL.persistKey) + ?: AppCategory.ALL.persistKey + return AppCategory.fromPersistKey(categoryKey) + } + + private fun loadCurrentSortType(): SortType { + val sortKey = prefs.getString(KEY_CURRENT_SORT_TYPE, SortType.NAME_ASC.persistKey) + ?: SortType.NAME_ASC.persistKey + return SortType.fromPersistKey(sortKey) + } + + fun updateShowSystemApps(newValue: Boolean) { + showSystemApps = newValue + prefs.edit { putBoolean(KEY_SHOW_SYSTEM_APPS, newValue) } + notifyAppListChanged() + } + + private fun notifyAppListChanged() { + val currentApps = apps + apps = emptyList() + apps = currentApps + } + + fun updateSelectedCategory(newCategory: AppCategory) { + selectedCategory = newCategory + prefs.edit { putString(KEY_SELECTED_CATEGORY, newCategory.persistKey) } + } + + fun updateCurrentSortType(newSortType: SortType) { + currentSortType = newSortType + prefs.edit { putString(KEY_CURRENT_SORT_TYPE, newSortType.persistKey) } + } + + fun toggleBatchMode() { + showBatchActions = !showBatchActions + if (!showBatchActions) clearSelection() + } + + fun toggleAppSelection(packageName: String) { + selectedApps = if (selectedApps.contains(packageName)) { + selectedApps - packageName + } else { + selectedApps + packageName + } + } + + fun clearSelection() { + selectedApps = emptySet() + } + + suspend fun updateBatchPermissions(allowSu: Boolean, umountModules: Boolean? = null) { + selectedApps.forEach { packageName -> + apps.find { it.packageName == packageName }?.let { app -> + val profile = Natives.getAppProfile(packageName, app.uid) + val updatedProfile = profile.copy( + allowSu = allowSu, + umountModules = umountModules ?: profile.umountModules, + nonRootUseDefault = false + ) + if (Natives.setAppProfile(updatedProfile)) { + updateAppProfileLocally(packageName, updatedProfile) + notifyConfigChange(packageName) + } + } + } + clearSelection() + showBatchActions = false + refreshAppConfigurations() + } + + fun updateAppProfileLocally(packageName: String, updatedProfile: Natives.Profile) { + appListMutex.tryLock().let { locked -> + if (locked) { + try { + apps = apps.map { app -> + if (app.packageName == packageName) { + app.copy(profile = updatedProfile) + } else app + } + } finally { + appListMutex.unlock() + } + } + } + } + + private fun notifyConfigChange(packageName: String) { + configChangeListeners.forEach { listener -> + try { + listener(packageName) + } catch (e: Exception) { + Log.e(TAG, "Error notifying config change for $packageName", e) + } + } + } + + suspend fun refreshAppConfigurations() { + withContext(appProcessingThreadPool) { + supervisorScope { + val currentApps = apps.toList() + val batches = currentApps.chunked(BATCH_SIZE) + loadingProgress = 0f + + val updatedApps = batches.mapIndexed { batchIndex, batch -> + async { + val batchResult = batch.map { app -> + try { + val updatedProfile = Natives.getAppProfile(app.packageName, app.uid) + app.copy(profile = updatedProfile) + } catch (e: Exception) { + Log.e(TAG, "Error refreshing profile for ${app.packageName}", e) + app + } + } + loadingProgress = (batchIndex + 1).toFloat() / batches.size + batchResult + } + }.awaitAll().flatten() + + appListMutex.withLock { apps = updatedApps } + loadingProgress = 1f + } + } + } + + private var serviceConnection: ServiceConnection? = null + + private suspend fun connectKsuService(onDisconnect: () -> Unit = {}): IBinder? = + suspendCoroutine { continuation -> + val connection = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName?) { + onDisconnect() + serviceConnection = null + } + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + continuation.resume(binder) + } + } + serviceConnection = connection + val intent = Intent(ksuApp, KsuService::class.java) + try { + val task = com.topjohnwu.superuser.ipc.RootService.bindOrTask( + intent, Shell.EXECUTOR, connection + ) + task?.let { Shell.getShell().execTask(it) } + } catch (e: Exception) { + Log.e(TAG, "Failed to bind KsuService", e) + continuation.resume(null) + } + } + + private fun stopKsuService() { + serviceConnection?.let { + try { + val intent = Intent(ksuApp, KsuService::class.java) + com.topjohnwu.superuser.ipc.RootService.stop(intent) + serviceConnection = null + } catch (e: Exception) { + Log.e(TAG, "Failed to stop KsuService", e) + } + } + } + + suspend fun fetchAppList() { + isRefreshing = true + loadingProgress = 0f + + val binder = connectKsuService() ?: run { isRefreshing = false; return } + + withContext(Dispatchers.IO) { + val pm = ksuApp.packageManager + val allPackages = IKsuInterface.Stub.asInterface(binder) + val total = allPackages.packageCount + val pageSize = 100 + val result = mutableListOf() + + var start = 0 + while (start < total) { + val page = allPackages.getPackages(start, pageSize) + if (page.isEmpty()) break + + result += page.mapNotNull { packageInfo -> + packageInfo.applicationInfo?.let { appInfo -> + AppInfo( + label = appInfo.loadLabel(pm).toString(), + packageInfo = packageInfo, + profile = Natives.getAppProfile(packageInfo.packageName, appInfo.uid) + ) + } + } + start += page.size + loadingProgress = start.toFloat() / total + } + + stopKsuService() + + synchronized(appsLock) { + _isAppListLoaded.value = true + } + + appListMutex.withLock { + val filteredApps = result.filter { it.packageName != ksuApp.packageName } + apps = filteredApps + appGroups = groupAppsByUid(filteredApps) + } + loadingProgress = 1f + } + isRefreshing = false + } + + val appGroupList by derivedStateOf { + appGroups.filter { group -> + group.apps.any { app -> + app.label.contains(search, true) || + app.packageName.contains(search, true) || + HanziToPinyin.getInstance().toPinyinString(app.label)?.contains(search, true) == true + } + }.filter { group -> + group.uid == 2000 || showSystemApps || + group.apps.any { it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 } + } + } + + private fun groupAppsByUid(appList: List): List { + return appList.groupBy { it.uid } + .map { (uid, apps) -> + val sortedApps = apps.sortedBy { it.label } + val profile = apps.firstOrNull()?.let { Natives.getAppProfile(it.packageName, uid) } + AppGroup(uid = uid, apps = sortedApps, profile = profile) + } + .sortedWith( + compareBy { + when { + it.allowSu -> 0 + it.hasCustomProfile -> 1 + else -> 2 + } + }.thenBy(Collator.getInstance(Locale.getDefault())) { + it.userName?.takeIf { name -> name.isNotBlank() } ?: it.uid.toString() + }.thenBy(Collator.getInstance(Locale.getDefault())) { it.mainApp.label } + ) +} + override fun onCleared() { + super.onCleared() + try { + stopKsuService() + appProcessingThreadPool.close() + configChangeListeners.clear() + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up resources", e) + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/TemplateViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/TemplateViewModel.kt new file mode 100644 index 0000000..7aa5b94 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/TemplateViewModel.kt @@ -0,0 +1,328 @@ +package com.sukisu.ultra.ui.viewmodel + +import android.os.Parcelable +import android.util.Log +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.sukisu.ultra.Natives +import com.sukisu.ultra.profile.Capabilities +import com.sukisu.ultra.profile.Groups +import com.sukisu.ultra.ui.util.getAppProfileTemplate +import com.sukisu.ultra.ui.util.listAppProfileTemplates +import com.sukisu.ultra.ui.util.setAppProfileTemplate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.text.Collator +import java.util.* +import java.util.concurrent.TimeUnit + + +/** + * @author weishu + * @date 2023/10/20. + */ +const val TEMPLATE_INDEX_URL = "https://kernelsu.org/templates/index.json" +const val TEMPLATE_URL = "https://kernelsu.org/templates/%s" + +const val TAG = "TemplateViewModel" + +class TemplateViewModel : ViewModel() { + companion object { + + private var templates by mutableStateOf>(emptyList()) + } + + @Parcelize + data class TemplateInfo( + val id: String = "", + val name: String = "", + val description: String = "", + val author: String = "", + val local: Boolean = true, + + val namespace: Int = Natives.Profile.Namespace.INHERITED.ordinal, + val uid: Int = Natives.ROOT_UID, + val gid: Int = Natives.ROOT_GID, + val groups: List = mutableListOf(), + val capabilities: List = mutableListOf(), + val context: String = Natives.KERNEL_SU_DOMAIN, + val rules: List = mutableListOf(), + ) : Parcelable + + var isRefreshing by mutableStateOf(false) + private set + + val templateList by derivedStateOf { + val comparator = compareBy(TemplateInfo::local).reversed().then( + compareBy( + Collator.getInstance(Locale.getDefault()), TemplateInfo::id + ) + ) + templates.sortedWith(comparator).apply { + isRefreshing = false + } + } + + suspend fun fetchTemplates(sync: Boolean = false) { + isRefreshing = true + withContext(Dispatchers.IO) { + val localTemplateIds = listAppProfileTemplates() + Log.i(TAG, "localTemplateIds: $localTemplateIds") + if (localTemplateIds.isEmpty() || sync) { + // if no templates, fetch remote templates + fetchRemoteTemplates() + } + + // fetch templates again + templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById) + + isRefreshing = false + } + } + + suspend fun importTemplates( + templates: String, + onSuccess: suspend () -> Unit, + onFailure: suspend (String) -> Unit + ) { + withContext(Dispatchers.IO) { + runCatching { + JSONArray(templates) + }.getOrElse { + runCatching { + val json = JSONObject(templates) + JSONArray().apply { put(json) } + }.getOrElse { + onFailure("invalid templates: $templates") + return@withContext + } + }.let { + 0.until(it.length()).forEach { i -> + runCatching { + val template = it.getJSONObject(i) + val id = template.getString("id") + template.put("local", true) + setAppProfileTemplate(id, template.toString()) + }.onFailure { e -> + Log.e(TAG, "ignore invalid template: $it", e) + } + } + onSuccess() + } + } + } + + suspend fun exportTemplates(onTemplateEmpty: () -> Unit, callback: (String) -> Unit) { + withContext(Dispatchers.IO) { + val templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById).filter { + it.local + } + templates.ifEmpty { + onTemplateEmpty() + return@withContext + } + JSONArray(templates.map { + it.toJSON() + }).toString().let(callback) + } + } +} + +private fun fetchRemoteTemplates() { + runCatching { + val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + client.newCall( + Request.Builder().url(TEMPLATE_INDEX_URL).build() + ).execute().use { response -> + if (!response.isSuccessful) { + return + } + val remoteTemplateIds = JSONArray(response.body!!.string()) + Log.i(TAG, "fetchRemoteTemplates: $remoteTemplateIds") + 0.until(remoteTemplateIds.length()).forEach { i -> + val id = remoteTemplateIds.getString(i) + Log.i(TAG, "fetch template: $id") + val templateJson = client.newCall( + Request.Builder().url(TEMPLATE_URL.format(id)).build() + ).runCatching { + execute().use { response -> + if (!response.isSuccessful) { + return@forEach + } + response.body!!.string() + } + }.getOrNull() ?: return@forEach + Log.i(TAG, "template: $templateJson") + + // validate remote template + runCatching { + val json = JSONObject(templateJson) + fromJSON(json)?.let { + // force local template + json.put("local", false) + setAppProfileTemplate(id, json.toString()) + } + }.onFailure { + Log.e(TAG, "ignore invalid template: $it", it) + return@forEach + } + } + } + }.onFailure { Log.e(TAG, "fetchRemoteTemplates: $it", it) } +} + +@Suppress("UNCHECKED_CAST") +private fun JSONArray.mapCatching( + transform: (T) -> R, onFail: (Throwable) -> Unit +): List { + return List(length()) { i -> get(i) as T }.mapNotNull { element -> + runCatching { + transform(element) + }.onFailure(onFail).getOrNull() + } +} + +private inline fun > getEnumOrdinals( + jsonArray: JSONArray?, enumClass: Class +): List { + return jsonArray?.mapCatching({ name -> + enumValueOf(name.uppercase()) + }, { + Log.e(TAG, "ignore invalid enum ${enumClass.simpleName}: $it", it) + }).orEmpty() +} + +fun getTemplateInfoById(id: String): TemplateViewModel.TemplateInfo? { + return runCatching { + fromJSON(JSONObject(getAppProfileTemplate(id))) + }.onFailure { + Log.e(TAG, "ignore invalid template: $it", it) + }.getOrNull() +} + +private fun getLocaleString(json: JSONObject, key: String): String { + val fallback = json.getString(key) + val locale = Locale.getDefault() + val localeKey = "${locale.language}_${locale.country}" + json.optJSONObject("locales")?.let { + // check locale first + it.optJSONObject(localeKey)?.let { json-> + return json.optString(key, fallback) + } + // fallback to language + it.optJSONObject(locale.language)?.let { json-> + return json.optString(key, fallback) + } + } + return fallback +} + +private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo? { + return runCatching { + val groupsJsonArray = templateJson.optJSONArray("groups") + val capabilitiesJsonArray = templateJson.optJSONArray("capabilities") + val context = templateJson.optString("context").takeIf { it.isNotEmpty() } + ?: Natives.KERNEL_SU_DOMAIN + val namespace = templateJson.optString("namespace").takeIf { it.isNotEmpty() } + ?: Natives.Profile.Namespace.INHERITED.name + + val rulesJsonArray = templateJson.optJSONArray("rules") + val templateInfo = TemplateViewModel.TemplateInfo( + id = templateJson.getString("id"), + name = getLocaleString(templateJson, "name"), + description = getLocaleString(templateJson, "description"), + author = templateJson.optString("author"), + local = templateJson.optBoolean("local"), + namespace = Natives.Profile.Namespace.valueOf( + namespace.uppercase() + ).ordinal, + uid = templateJson.optInt("uid", Natives.ROOT_UID), + gid = templateJson.optInt("gid", Natives.ROOT_GID), + groups = getEnumOrdinals(groupsJsonArray, Groups::class.java).map { it.gid }, + capabilities = getEnumOrdinals( + capabilitiesJsonArray, Capabilities::class.java + ).map { it.cap }, + context = context, + rules = rulesJsonArray?.mapCatching({ it }, { + Log.e(TAG, "ignore invalid rule: $it", it) + }).orEmpty() + ) + templateInfo + }.onFailure { + Log.e(TAG, "ignore invalid template: $it", it) + }.getOrNull() +} + +fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject { + val template = this + return JSONObject().apply { + + put("id", template.id) + put("name", template.name.ifBlank { template.id }) + put("description", template.description.ifBlank { template.id }) + if (template.author.isNotEmpty()) { + put("author", template.author) + } + put("namespace", Natives.Profile.Namespace.entries[template.namespace].name) + put("uid", template.uid) + put("gid", template.gid) + + if (template.groups.isNotEmpty()) { + put("groups", JSONArray( + Groups.entries.filter { + template.groups.contains(it.gid) + }.map { + it.name + } + )) + } + + if (template.capabilities.isNotEmpty()) { + put("capabilities", JSONArray( + Capabilities.entries.filter { + template.capabilities.contains(it.cap) + }.map { + it.name + } + )) + } + + if (template.context.isNotEmpty()) { + put("context", template.context) + } + + if (template.rules.isNotEmpty()) { + put("rules", JSONArray(template.rules)) + } + } +} + +@Suppress("unused") +fun generateTemplates() { + val templateJson = JSONObject() + templateJson.put("id", "com.example") + templateJson.put("name", "Example") + templateJson.put("description", "This is an example template") + templateJson.put("local", true) + templateJson.put("namespace", Natives.Profile.Namespace.INHERITED.name) + templateJson.put("uid", 0) + templateJson.put("gid", 0) + + templateJson.put("groups", JSONArray().apply { put(Groups.INET.name) }) + templateJson.put("capabilities", JSONArray().apply { put(Capabilities.CAP_NET_RAW.name) }) + templateJson.put("context", "u:r:su:s0") + Log.i(TAG, "$templateJson") +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/AppIconUtil.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/AppIconUtil.kt new file mode 100644 index 0000000..361a976 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/AppIconUtil.kt @@ -0,0 +1,46 @@ +package com.sukisu.ultra.ui.webui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.LruCache +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel.Companion.getAppIconDrawable + +object AppIconUtil { + // Limit cache size to 200 icons + private const val CACHE_SIZE = 200 + private val iconCache = LruCache(CACHE_SIZE) + + @Synchronized + fun loadAppIconSync(context: Context, packageName: String, sizePx: Int): Bitmap? { + val cached = iconCache.get(packageName) + if (cached != null) return cached + + try { + val drawable = getAppIconDrawable(context, packageName) ?: return null + val raw = drawableToBitmap(drawable, sizePx) + val icon = raw.scale(sizePx, sizePx) + iconCache.put(packageName, icon) + return icon + } catch (_: Exception) { + return null + } + } + + private fun drawableToBitmap(drawable: Drawable, size: Int): Bitmap { + if (drawable is BitmapDrawable) return drawable.bitmap + + val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else size + val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else size + + val bmp = createBitmap(width, height) + val canvas = Canvas(bmp) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bmp + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/Insets.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/Insets.kt new file mode 100644 index 0000000..aabdbe2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/Insets.kt @@ -0,0 +1,40 @@ +package com.sukisu.ultra.ui.webui + +/** + * Insets data class from GitHub@MMRLApp/WebUI-X-Portable + * + * Data class representing insets (top, bottom, left, right) for a view. + * + * This class provides methods to generate CSS code that can be injected into a WebView + * to apply these insets as CSS variables. This is useful for adapting web content + * to the safe areas of a device screen, considering notches, status bars, and navigation bars. + * + * @property top The top inset value in pixels. + * @property bottom The bottom inset value in pixels. + * @property left The left inset value in pixels. + * @property right The right inset value in pixels. + */ +data class Insets( + val top: Int, + val bottom: Int, + val left: Int, + val right: Int, +) { + val css + get() = buildString { + appendLine(":root {") + appendLine("\t--safe-area-inset-top: ${top}px;") + appendLine("\t--safe-area-inset-right: ${right}px;") + appendLine("\t--safe-area-inset-bottom: ${bottom}px;") + appendLine("\t--safe-area-inset-left: ${left}px;") + appendLine("\t--window-inset-top: var(--safe-area-inset-top, 0px);") + appendLine("\t--window-inset-bottom: var(--safe-area-inset-bottom, 0px);") + appendLine("\t--window-inset-left: var(--safe-area-inset-left, 0px);") + appendLine("\t--window-inset-right: var(--safe-area-inset-right, 0px);") + appendLine("\t--f7-safe-area-top: var(--window-inset-top, 0px) !important;") + appendLine("\t--f7-safe-area-bottom: var(--window-inset-bottom, 0px) !important;") + appendLine("\t--f7-safe-area-left: var(--window-inset-left, 0px) !important;") + appendLine("\t--f7-safe-area-right: var(--window-inset-right, 0px) !important;") + append("}") + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/KsuLibSuProvider.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/KsuLibSuProvider.kt new file mode 100644 index 0000000..dad41e3 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/KsuLibSuProvider.kt @@ -0,0 +1,56 @@ +package com.sukisu.ultra.ui.webui + +import android.content.ServiceConnection +import android.util.Log +import com.dergoogler.mmrl.platform.Platform +import com.dergoogler.mmrl.platform.model.IProvider +import com.dergoogler.mmrl.platform.model.PlatformIntent +import com.sukisu.ultra.Natives +import com.sukisu.ultra.ksuApp +import com.topjohnwu.superuser.ipc.RootService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +class KsuLibSuProvider : IProvider { + override val name = "KsuLibSu" + + override fun isAvailable() = true + + override suspend fun isAuthorized() = Natives.isManager + + private val serviceIntent + get() = PlatformIntent( + ksuApp, + Platform.KsuNext, + SuService::class.java + ) + + override fun bind(connection: ServiceConnection) { + RootService.bind(serviceIntent.intent, connection) + } + + override fun unbind(connection: ServiceConnection) { + RootService.stop(serviceIntent.intent) + } +} + +// webui x +suspend fun initPlatform() = withContext(Dispatchers.IO) { + try { + val active = Platform.init { + this.context = ksuApp + this.platform = Platform.KsuNext + this.provider = from(KsuLibSuProvider()) + } + + while (!active) { + delay(1000) + } + + return@withContext true + } catch (e: Exception) { + Log.e("KsuLibSu", "Failed to initialize platform", e) + return@withContext false + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/MimeUtil.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/MimeUtil.kt new file mode 100644 index 0000000..b9adcfa --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/MimeUtil.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.sukisu.ultra.ui.webui + +import java.net.URLConnection + +internal object MimeUtil { + fun getMimeFromFileName(fileName: String?): String? { + if (fileName == null) { + return null + } + + val mimeType = URLConnection.guessContentTypeFromName(fileName) + if (mimeType != null) { + return mimeType + } + + return guessHardcodedMime(fileName) + } + + private fun guessHardcodedMime(fileName: String): String? { + val finalFullStop = fileName.lastIndexOf('.') + if (finalFullStop == -1) { + return null + } + + val extension = fileName.substring(finalFullStop + 1).lowercase() + + return when (extension) { + "webm" -> "video/webm" + "mpeg", "mpg" -> "video/mpeg" + "mp3" -> "audio/mpeg" + "wasm" -> "application/wasm" + "xhtml", "xht", "xhtm" -> "application/xhtml+xml" + "flac" -> "audio/flac" + "ogg", "oga", "opus" -> "audio/ogg" + "wav" -> "audio/wav" + "m4a" -> "audio/x-m4a" + "gif" -> "image/gif" + "jpeg", "jpg", "jfif", "pjpeg", "pjp" -> "image/jpeg" + "png" -> "image/png" + "apng" -> "image/apng" + "svg", "svgz" -> "image/svg+xml" + "webp" -> "image/webp" + "mht", "mhtml" -> "multipart/related" + "css" -> "text/css" + "html", "htm", "shtml", "shtm", "ehtml" -> "text/html" + "js", "mjs" -> "application/javascript" + "xml" -> "text/xml" + "mp4", "m4v" -> "video/mp4" + "ogv", "ogm" -> "video/ogg" + "ico" -> "image/x-icon" + "woff" -> "application/font-woff" + "gz", "tgz" -> "application/gzip" + "json" -> "application/json" + "pdf" -> "application/pdf" + "zip" -> "application/zip" + "bmp" -> "image/bmp" + "tiff", "tif" -> "image/tiff" + else -> null + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt new file mode 100644 index 0000000..c0f7930 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuFilePathHandler.kt @@ -0,0 +1,192 @@ +package com.sukisu.ultra.ui.webui + +import android.content.Context +import android.util.Log +import android.webkit.WebResourceResponse +import androidx.annotation.WorkerThread +import androidx.webkit.WebViewAssetLoader +import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.io.SuFile +import com.topjohnwu.superuser.io.SuFileInputStream +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.zip.GZIPInputStream + +/** + * Handler class to open files from file system by root access + * For more information about android storage please refer to + * [Android Developers Docs: Data and file storage overview](https://developer.android.com/guide/topics/data/data-storage). + * + * To avoid leaking user or app data to the web, make sure to choose [directory] + * carefully, and assume any file under this directory could be accessed by any web page subject + * to same-origin rules. + * + * A typical usage would be like: + * ``` + * val publicDir = File(context.filesDir, "public") + * // Host "files/public/" in app's data directory under: + * // http://appassets.androidplatform.net/public/... + * val assetLoader = WebViewAssetLoader.Builder() + * .addPathHandler("/public/", SuFilePathHandler(context, publicDir, shell, insetsSupplier)) + * .build() + * ``` + */ +class SuFilePathHandler( + directory: File, + private val shell: Shell, + private val insetsSupplier: InsetsSupplier +) : WebViewAssetLoader.PathHandler { + + private val directory: File + + init { + try { + this.directory = File(getCanonicalDirPath(directory)) + if (!isAllowedInternalStorageDir()) { + throw IllegalArgumentException( + "The given directory \"$directory\" doesn't exist under an allowed app internal storage directory" + ) + } + } catch (e: IOException) { + throw IllegalArgumentException( + "Failed to resolve the canonical path for the given directory: ${directory.path}", + e + ) + } + } + + fun interface InsetsSupplier { + fun get(): Insets + } + + private fun isAllowedInternalStorageDir(): Boolean { + return try { + val dir = getCanonicalDirPath(directory) + FORBIDDEN_DATA_DIRS.none { dir.startsWith(it) } + } catch (_: IOException) { + false + } + } + + /** + * Opens the requested file from the exposed data directory. + * + * The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the + * requested file cannot be found or is outside the mounted directory a + * [WebResourceResponse] object with a `null` [InputStream] will be + * returned instead of `null`. This saves the time of falling back to network and + * trying to resolve a path that doesn't exist. A [WebResourceResponse] with + * `null` [InputStream] will be received as an HTTP response with status code + * `404` and no body. + * + * The MIME type for the file will be determined from the file's extension using + * [java.net.URLConnection.guessContentTypeFromName]. Developers should ensure that + * files are named using standard file extensions. If the file does not have a + * recognised extension, `"text/plain"` will be used by default. + * + * @param path the suffix path to be handled. + * @return [WebResourceResponse] for the requested file. + */ + @WorkerThread + override fun handle(path: String): WebResourceResponse { + if (path == "internal/insets.css") { + val css = insetsSupplier.get().css + return WebResourceResponse( + "text/css", + "utf-8", + ByteArrayInputStream(css.toByteArray(StandardCharsets.UTF_8)) + ) + } + + try { + val file = getCanonicalFileIfChild(directory, path) + if (file != null) { + val inputStream = openFile(file, shell) + val mimeType = guessMimeType(path) + return WebResourceResponse(mimeType, null, inputStream) + } else { + Log.e( + TAG, + "The requested file: $path is outside the mounted directory: $directory" + ) + } + } catch (e: IOException) { + Log.e(TAG, "Error opening the requested path: $path", e) + } + + return WebResourceResponse(null, null, null) + } + + companion object { + private const val TAG = "SuFilePathHandler" + + /** + * Default value to be used as MIME type if guessing MIME type failed. + */ + const val DEFAULT_MIME_TYPE = "text/plain" + + /** + * Forbidden subdirectories of [Context.getDataDir] that cannot be exposed by this + * handler. They are forbidden as they often contain sensitive information. + * + * Note: Any future addition to this list will be considered breaking changes to the API. + */ + private val FORBIDDEN_DATA_DIRS = arrayOf("/data/data", "/data/system") + + @JvmStatic + @Throws(IOException::class) + fun getCanonicalDirPath(file: File): String { + var canonicalPath = file.canonicalPath + if (!canonicalPath.endsWith("/")) { + canonicalPath += "/" + } + return canonicalPath + } + + @JvmStatic + @Throws(IOException::class) + fun getCanonicalFileIfChild(parent: File, child: String): File? { + val parentCanonicalPath = getCanonicalDirPath(parent) + val childCanonicalPath = File(parent, child).canonicalPath + return if (childCanonicalPath.startsWith(parentCanonicalPath)) { + File(childCanonicalPath) + } else { + null + } + } + + @Throws(IOException::class) + private fun handleSvgzStream(path: String, stream: InputStream): InputStream { + return if (path.endsWith(".svgz")) { + GZIPInputStream(stream) + } else { + stream + } + } + + @JvmStatic + @Throws(IOException::class) + fun openFile(file: File, shell: Shell): InputStream { + val suFile = SuFile(file.absolutePath).apply { + setShell(shell) + } + val fis = SuFileInputStream.open(suFile) + return handleSvgzStream(file.path, fis) + } + + /** + * Use [MimeUtil.getMimeFromFileName] to guess MIME type or return the + * [DEFAULT_MIME_TYPE] if it can't guess. + * + * @param filePath path of the file to guess its MIME type. + * @return MIME type guessed from file extension or [DEFAULT_MIME_TYPE]. + */ + @JvmStatic + fun guessMimeType(filePath: String): String { + return MimeUtil.getMimeFromFileName(filePath) ?: DEFAULT_MIME_TYPE + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuService.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuService.kt new file mode 100644 index 0000000..5a421f2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/SuService.kt @@ -0,0 +1,14 @@ +package com.sukisu.ultra.ui.webui + +import android.content.Intent +import android.os.IBinder +import com.dergoogler.mmrl.platform.model.PlatformIntent.Companion.getPlatform +import com.dergoogler.mmrl.platform.service.ServiceManager +import com.topjohnwu.superuser.ipc.RootService + +class SuService : RootService() { + override fun onBind(intent: Intent): IBinder { + val mode = intent.getPlatform() + return ServiceManager(mode) + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt new file mode 100644 index 0000000..91ecd6c --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIActivity.kt @@ -0,0 +1,149 @@ +package com.sukisu.ultra.ui.webui + +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import androidx.webkit.WebViewAssetLoader +import com.dergoogler.mmrl.platform.model.ModId +import com.dergoogler.mmrl.webui.interfaces.WXOptions +import com.sukisu.ultra.ui.util.createRootShell +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.io.File + +@SuppressLint("SetJavaScriptEnabled") +class WebUIActivity : ComponentActivity() { + private val rootShell by lazy { createRootShell(true) } + + private lateinit var insets: Insets + private var webView = null as WebView? + + override fun onCreate(savedInstanceState: Bundle?) { + + // Enable edge to edge + enableEdgeToEdge() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + + super.onCreate(savedInstanceState) + + setContent { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + lifecycleScope.launch { + SuperUserViewModel.isAppListLoaded.first { it } + setupWebView() + } + } + private fun setupWebView() { + val moduleId = intent.getStringExtra("id") ?: finishAndRemoveTask().let { return } + val name = intent.getStringExtra("name") ?: finishAndRemoveTask().let { return } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + setTaskDescription(ActivityManager.TaskDescription("SukiSU-Ultra - $name")) + } else { + val taskDescription = + ActivityManager.TaskDescription.Builder().setLabel("SukiSU-Ultra - $name").build() + setTaskDescription(taskDescription) + } + + val prefs = getSharedPreferences("settings", MODE_PRIVATE) + WebView.setWebContentsDebuggingEnabled(prefs.getBoolean("enable_web_debugging", false)) + + val moduleDir = "/data/adb/modules/${moduleId}" + val webRoot = File("${moduleDir}/webroot") + insets = Insets(0, 0, 0, 0) + val webViewAssetLoader = WebViewAssetLoader.Builder() + .setDomain("mui.kernelsu.org") + .addPathHandler( + "/", + SuFilePathHandler(webRoot, rootShell) { insets } + ) + .build() + + val webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url + // Handle ksu://icon/[packageName] to serve app icon via WebView + if (url.scheme.equals("ksu", ignoreCase = true) && url.host.equals("icon", ignoreCase = true)) { + val packageName = url.path?.substring(1) + if (!packageName.isNullOrEmpty()) { + val icon = AppIconUtil.loadAppIconSync(this@WebUIActivity, packageName, 512) + if (icon != null) { + val stream = java.io.ByteArrayOutputStream() + icon.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) + val inputStream = java.io.ByteArrayInputStream(stream.toByteArray()) + return WebResourceResponse("image/png", null, inputStream) + } + } + } + return webViewAssetLoader.shouldInterceptRequest(url) + } + } + + val webView = WebView(this).apply { + webView = this + + setBackgroundColor(Color.TRANSPARENT) + val density = resources.displayMetrics.density + + ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets -> + val inset = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + insets = Insets( + top = (inset.top / density).toInt(), + bottom = (inset.bottom / density).toInt(), + left = (inset.left / density).toInt(), + right = (inset.right / density).toInt() + ) + WindowInsetsCompat.CONSUMED + } + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + addJavascriptInterface(WebViewInterface(WXOptions(this@WebUIActivity, this, ModId(moduleId))), "ksu") + setWebViewClient(webViewClient) + loadUrl("https://mui.kernelsu.org/index.html") + } + + setContentView(webView) + } + + override fun onDestroy() { + rootShell.runCatching { close() } + webView?.apply { + stopLoading() + removeAllViews() + destroy() + webView = null + } + super.onDestroy() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIXActivity.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIXActivity.kt new file mode 100644 index 0000000..0761274 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebUIXActivity.kt @@ -0,0 +1,116 @@ +package com.sukisu.ultra.ui.webui + +import android.app.ActivityManager +import android.os.Build +import android.os.Bundle +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.* +import androidx.lifecycle.lifecycleScope +import com.dergoogler.mmrl.platform.Platform +import com.dergoogler.mmrl.platform.model.ModId +import com.dergoogler.mmrl.ui.component.Loading +import com.dergoogler.mmrl.webui.model.WebUIConfig +import com.dergoogler.mmrl.webui.screen.WebUIScreen +import com.dergoogler.mmrl.webui.util.rememberWebUIOptions +import com.sukisu.ultra.BuildConfig +import com.sukisu.ultra.ui.theme.KernelSUTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class WebUIXActivity : ComponentActivity() { + private lateinit var webView: WebView + + private val userAgent + get(): String { + val ksuVersion = BuildConfig.VERSION_CODE + + val platform = Platform.get("Unknown") { + platform.name + } + + val platformVersion = Platform.get(-1) { + moduleManager.versionCode + } + + val osVersion = Build.VERSION.RELEASE + val deviceModel = Build.MODEL + + return "SukiSU-Ultra /$ksuVersion (Linux; Android $osVersion; $deviceModel; $platform/$platformVersion)" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + webView = WebView(this) + + lifecycleScope.launch { + initPlatform() + } + + val moduleId = intent.getStringExtra("id")!! + val name = intent.getStringExtra("name")!! + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + setTaskDescription(ActivityManager.TaskDescription("SukiSU-Ultra - $name")) + } else { + val taskDescription = + ActivityManager.TaskDescription.Builder().setLabel("SukiSU-Ultra - $name").build() + setTaskDescription(taskDescription) + } + + val prefs = getSharedPreferences("settings", MODE_PRIVATE) + + setContent { + KernelSUTheme { + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(Platform.isAlive) { + while (!Platform.isAlive) { + delay(1000) + } + + isLoading = false + } + + if (isLoading) { + Loading() + return@KernelSUTheme + } + + val webDebugging = prefs.getBoolean("enable_web_debugging", false) + val erudaInject = prefs.getBoolean("use_webuix_eruda", false) + val dark = isSystemInDarkTheme() + + val options = rememberWebUIOptions( + modId = ModId(moduleId), + debug = webDebugging, + appVersionCode = BuildConfig.VERSION_CODE, + isDarkMode = dark, + enableEruda = erudaInject, + cls = WebUIXActivity::class.java, + userAgentString = userAgent + ) + + // idk why webuix not allow root impl change webuiConfig + // so we use magic to force exitConfirm shutdown + val field = WebUIConfig::class.java.getDeclaredField("exitConfirm") + field.isAccessible = true + field.set(options.config, false) + field.isAccessible = false + + WebUIScreen( + webView = webView, + options = options, + interfaces = listOf( + WebViewInterface.factory() + ) + ) + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt new file mode 100644 index 0000000..1e27104 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/webui/WebViewInterface.kt @@ -0,0 +1,279 @@ +package com.sukisu.ultra.ui.webui + +import android.app.Activity +import android.content.pm.ApplicationInfo +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import android.view.Window +import android.webkit.JavascriptInterface +import android.widget.Toast +import androidx.core.content.pm.PackageInfoCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.dergoogler.mmrl.webui.interfaces.WXInterface +import com.dergoogler.mmrl.webui.interfaces.WXOptions +import com.dergoogler.mmrl.webui.model.JavaScriptInterface +import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import com.sukisu.ultra.ui.util.* +import com.topjohnwu.superuser.CallbackList +import com.topjohnwu.superuser.ShellUtils +import com.topjohnwu.superuser.internal.UiThreadHandler +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.CompletableFuture + +@Suppress("unused") +class WebViewInterface( + wxOptions: WXOptions, +) : WXInterface(wxOptions) { + override var name: String = "ksu" + + companion object { + fun factory() = JavaScriptInterface(WebViewInterface::class.java) + } + + private val modDir get() = "/data/adb/modules/${modId.id}" + + @JavascriptInterface + fun exec(cmd: String): String { + return withNewRootShell(true) { ShellUtils.fastCmd(this, cmd) } + } + + @JavascriptInterface + fun exec(cmd: String, callbackFunc: String) { + exec(cmd, null, callbackFunc) + } + + private fun processOptions(sb: StringBuilder, options: String?) { + val opts = if (options == null) JSONObject() else { + JSONObject(options) + } + + val cwd = opts.optString("cwd") + if (!TextUtils.isEmpty(cwd)) { + sb.append("cd ${cwd};") + } + + opts.optJSONObject("env")?.let { env -> + env.keys().forEach { key -> + sb.append("export ${key}=${env.getString(key)};") + } + } + } + + @JavascriptInterface + fun exec( + cmd: String, + options: String?, + callbackFunc: String + ) { + val finalCommand = buildString { + processOptions(this, options) + append(cmd) + } + + val result = withNewRootShell(true) { + newJob().add(finalCommand).to(ArrayList(), ArrayList()).exec() + } + val stdout = result.out.joinToString(separator = "\n") + val stderr = result.err.joinToString(separator = "\n") + + val jsCode = + "(function() { try { ${callbackFunc}(${result.code}, ${ + JSONObject.quote( + stdout + ) + }, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();" + webView.post { + webView.evaluateJavascript(jsCode, null) + } + } + + @JavascriptInterface + fun spawn(command: String, args: String, options: String?, callbackFunc: String) { + val finalCommand = buildString { + processOptions(this, options) + + if (!TextUtils.isEmpty(args)) { + append(command).append(" ") + JSONArray(args).let { argsArray -> + for (i in 0 until argsArray.length()) { + append("${argsArray.getString(i)} ") + } + } + } else { + append(command) + } + } + + val shell = createRootShell(true) + + val emitData = fun(name: String, data: String) { + val jsCode = + "(function() { try { ${callbackFunc}.${name}.emit('data', ${ + JSONObject.quote( + data + ) + }); } catch(e) { console.error('emitData', e); } })();" + webView.post { + webView.evaluateJavascript(jsCode, null) + } + } + + val stdout = object : CallbackList(UiThreadHandler::runAndWait) { + override fun onAddElement(s: String) { + emitData("stdout", s) + } + } + + val stderr = object : CallbackList(UiThreadHandler::runAndWait) { + override fun onAddElement(s: String) { + emitData("stderr", s) + } + } + + val future = shell.newJob().add(finalCommand).to(stdout, stderr).enqueue() + val completableFuture = CompletableFuture.supplyAsync { + future.get() + } + + completableFuture.thenAccept { result -> + val emitExitCode = + $$"(function() { try { $${callbackFunc}.emit('exit', $${result.code}); } catch(e) { console.error(`emitExit error: ${e}`); } })();" + webView.post { + webView.evaluateJavascript(emitExitCode, null) + } + + if (result.code != 0) { + val emitErrCode = + "(function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${ + JSONObject.quote( + result.err.joinToString( + "\n" + ) + ) + };${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();" + webView.post { + webView.evaluateJavascript(emitErrCode, null) + } + } + }.whenComplete { _, _ -> + runCatching { shell.close() } + } + } + + @JavascriptInterface + fun toast(msg: String) { + webView.post { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + + @JavascriptInterface + fun fullScreen(enable: Boolean) { + if (context is Activity) { + Handler(Looper.getMainLooper()).post { + if (enable) { + hideSystemUI(activity.window) + } else { + showSystemUI(activity.window) + } + } + } + } + + @JavascriptInterface + fun moduleInfo(): String { + val moduleInfos = JSONArray(listModules()) + val currentModuleInfo = JSONObject() + currentModuleInfo.put("moduleDir", modDir) + val moduleId = File(modDir).getName() + for (i in 0 until moduleInfos.length()) { + val currentInfo = moduleInfos.getJSONObject(i) + + if (currentInfo.getString("id") != moduleId) { + continue + } + + val keys = currentInfo.keys() + for (key in keys) { + currentModuleInfo.put(key, currentInfo.get(key)) + } + break + } + return currentModuleInfo.toString() + } + + @JavascriptInterface + fun listPackages(type: String): String { + val packageNames = SuperUserViewModel.apps + .filter { appInfo -> + val flags = appInfo.packageInfo.applicationInfo?.flags ?: 0 + when (type.lowercase()) { + "system" -> (flags and ApplicationInfo.FLAG_SYSTEM) != 0 + "user" -> (flags and ApplicationInfo.FLAG_SYSTEM) == 0 + else -> true + } + } + .map { it.packageName } + .sorted() + + val jsonArray = JSONArray() + for (pkgName in packageNames) { + jsonArray.put(pkgName) + } + return jsonArray.toString() + } + + @JavascriptInterface + fun getPackagesInfo(packageNamesJson: String): String { + val packageNames = JSONArray(packageNamesJson) + val jsonArray = JSONArray() + val appMap = SuperUserViewModel.apps.associateBy { it.packageName } + for (i in 0 until packageNames.length()) { + val pkgName = packageNames.getString(i) + val appInfo = appMap[pkgName] + if (appInfo != null) { + val pkg = appInfo.packageInfo + val app = pkg.applicationInfo + val obj = JSONObject() + obj.put("packageName", pkg.packageName) + obj.put("versionName", pkg.versionName ?: "") + obj.put("versionCode", PackageInfoCompat.getLongVersionCode(pkg)) + obj.put("appLabel", appInfo.label) + obj.put("isSystem", if (app != null) ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) else JSONObject.NULL) + obj.put("uid", app?.uid ?: JSONObject.NULL) + jsonArray.put(obj) + } else { + val obj = JSONObject() + obj.put("packageName", pkgName) + obj.put("error", "Package not found or inaccessible") + jsonArray.put(obj) + } + } + return jsonArray.toString() + } + + // =================== KPM支持 ============================= + + @JavascriptInterface + fun listAllKpm(): String { + return listKpmModules() + } + + @JavascriptInterface + fun controlKpm(name: String, args: String): Int { + return controlKpmModule(name, args) + } +} + +fun hideSystemUI(window: Window) = + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + +fun showSystemUI(window: Window) = + WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars()) \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/utils/AssetsUtil.kt b/manager/app/src/main/java/com/sukisu/ultra/utils/AssetsUtil.kt new file mode 100644 index 0000000..91ad7c7 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/utils/AssetsUtil.kt @@ -0,0 +1,26 @@ +package com.sukisu.ultra.utils + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +object AssetsUtil { + @Throws(IOException::class) + fun exportFiles(context: Context, src: String, out: String) { + val fileNames = context.assets.list(src) + if (fileNames?.isNotEmpty() == true) { + val file = File(out) + file.mkdirs() + fileNames.forEach { fileName -> + exportFiles(context, "$src/$fileName", "$out/$fileName") + } + } else { + context.assets.open(src).use { inputStream -> + FileOutputStream(File(out)).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/KernelFlash.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/KernelFlash.kt new file mode 100644 index 0000000..a87558f --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/KernelFlash.kt @@ -0,0 +1,475 @@ +package zako.zako.zako.zakoui.screen.kernelFlash + +import android.content.Context +import android.net.Uri +import android.os.Environment +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.KeyEventBlocker +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.util.LocalSnackbarHost +import com.sukisu.ultra.ui.util.reboot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import zako.zako.zako.zakoui.screen.kernelFlash.state.FlashState +import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelState +import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelWorker +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +private object KernelFlashStateHolder { + var currentState: HorizonKernelState? = null + var currentUri: Uri? = null + var currentSlot: String? = null + var currentKpmPatchEnabled: Boolean = false + var currentKpmUndoPatch: Boolean = false + var isFlashing = false +} + +/** + * Kernel刷写界面 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun KernelFlashScreen( + navigator: DestinationsNavigator, + kernelUri: Uri, + selectedSlot: String? = null, + kpmPatchEnabled: Boolean = false, + kpmUndoPatch: Boolean = false +) { + val context = LocalContext.current + + val shouldAutoExit = remember { + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.getBoolean("auto_exit_after_flash", false) + } + + val scrollState = rememberScrollState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val snackBarHost = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + var logText by rememberSaveable { mutableStateOf("") } + var showFloatAction by rememberSaveable { mutableStateOf(false) } + val logContent = rememberSaveable { StringBuilder() } + val horizonKernelState = remember { + if (KernelFlashStateHolder.currentState != null && + KernelFlashStateHolder.currentUri == kernelUri && + KernelFlashStateHolder.currentSlot == selectedSlot && + KernelFlashStateHolder.currentKpmPatchEnabled == kpmPatchEnabled && + KernelFlashStateHolder.currentKpmUndoPatch == kpmUndoPatch) { + KernelFlashStateHolder.currentState!! + } else { + HorizonKernelState().also { + KernelFlashStateHolder.currentState = it + KernelFlashStateHolder.currentUri = kernelUri + KernelFlashStateHolder.currentSlot = selectedSlot + KernelFlashStateHolder.currentKpmPatchEnabled = kpmPatchEnabled + KernelFlashStateHolder.currentKpmUndoPatch = kpmUndoPatch + KernelFlashStateHolder.isFlashing = false + } + } + } + + val flashState by horizonKernelState.state.collectAsState() + val logSavedString = stringResource(R.string.log_saved) + + val onFlashComplete = { + showFloatAction = true + KernelFlashStateHolder.isFlashing = false + + // 如果需要自动退出,延迟1.5秒后退出 + if (shouldAutoExit) { + scope.launch { + delay(1500) + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.edit { remove("auto_exit_after_flash") } + (context as? ComponentActivity)?.finish() + } + } + } + + // 开始刷写 + LaunchedEffect(Unit) { + if (!KernelFlashStateHolder.isFlashing && !flashState.isCompleted && flashState.error.isEmpty()) { + withContext(Dispatchers.IO) { + KernelFlashStateHolder.isFlashing = true + val worker = HorizonKernelWorker( + context = context, + state = horizonKernelState, + slot = selectedSlot, + kpmPatchEnabled = kpmPatchEnabled, + kpmUndoPatch = kpmUndoPatch + ) + worker.uri = kernelUri + worker.setOnFlashCompleteListener(onFlashComplete) + worker.start() + + // 监听日志更新 + while (flashState.error.isEmpty()) { + if (flashState.logs.isNotEmpty()) { + logText = flashState.logs.joinToString("\n") + logContent.clear() + logContent.append(logText) + } + delay(100) + } + + if (flashState.error.isNotEmpty()) { + logText += "\n${flashState.error}\n" + logContent.append("\n${flashState.error}\n") + KernelFlashStateHolder.isFlashing = false + } + } + } else { + logText = flashState.logs.joinToString("\n") + if (flashState.error.isNotEmpty()) { + logText += "\n${flashState.error}\n" + } else if (flashState.isCompleted) { + logText += "\n${context.getString(R.string.horizon_flash_complete)}\n\n\n" + showFloatAction = true + } + } + } + + val onBack: () -> Unit = { + if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) { + // 清理全局状态 + if (flashState.isCompleted || flashState.error.isNotEmpty()) { + KernelFlashStateHolder.currentState = null + KernelFlashStateHolder.currentUri = null + KernelFlashStateHolder.currentSlot = null + KernelFlashStateHolder.currentKpmPatchEnabled = false + KernelFlashStateHolder.currentKpmUndoPatch = false + KernelFlashStateHolder.isFlashing = false + } + navigator.popBackStack() + } + } + + DisposableEffect(shouldAutoExit) { + onDispose { + if (shouldAutoExit) { + KernelFlashStateHolder.currentState = null + KernelFlashStateHolder.currentUri = null + KernelFlashStateHolder.currentSlot = null + KernelFlashStateHolder.currentKpmPatchEnabled = false + KernelFlashStateHolder.currentKpmUndoPatch = false + KernelFlashStateHolder.isFlashing = false + } + } + } + + BackHandler(enabled = true) { + onBack() + } + + Scaffold( + topBar = { + TopBar( + flashState = flashState, + onBack = onBack, + onSave = { + scope.launch { + val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) + val date = format.format(Date()) + val file = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "KernelSU_kernel_flash_log_${date}.log" + ) + file.writeText(logContent.toString()) + snackBarHost.showSnackbar(logSavedString.format(file.absolutePath)) + } + }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + if (showFloatAction) { + ExtendedFloatingActionButton( + onClick = { + scope.launch { + withContext(Dispatchers.IO) { + reboot() + } + } + }, + icon = { + Icon( + Icons.Filled.Refresh, + contentDescription = stringResource(id = R.string.reboot) + ) + }, + text = { + Text(text = stringResource(id = R.string.reboot)) + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + expanded = true + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackBarHost) }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + KeyEventBlocker { + it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + LaunchedEffect(logText) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Text( + modifier = Modifier.padding(16.dp), + text = logText, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun FlashProgressIndicator( + flashState: FlashState, + kpmPatchEnabled: Boolean = false, + kpmUndoPatch: Boolean = false +) { + val progressColor = when { + flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error + flashState.isCompleted -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } + + val progress = animateFloatAsState( + targetValue = flashState.progress, + label = "FlashProgress" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = when { + flashState.error.isNotEmpty() -> stringResource(R.string.flash_failed) + flashState.isCompleted -> stringResource(R.string.flash_success) + else -> stringResource(R.string.flashing) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = progressColor + ) + + when { + flashState.error.isNotEmpty() -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + flashState.isCompleted -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + } + } + } + + // KPM状态显示 + if (kpmPatchEnabled || kpmUndoPatch) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode) + else stringResource(R.string.kpm_patch_mode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (flashState.currentStep.isNotEmpty()) { + Text( + text = flashState.currentStep, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + LinearProgressIndicator( + progress = { progress.value }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = progressColor, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + + if (flashState.error.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = flashState.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.small + ) + .padding(8.dp) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + flashState: FlashState, + onBack: () -> Unit, + onSave: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val statusColor = when { + flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error + flashState.isCompleted -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } + + val colorScheme = MaterialTheme.colorScheme + val cardColor = if (CardConfig.isCustomBackgroundEnabled) { + colorScheme.surfaceContainerLow + } else { + colorScheme.background + } + val cardAlpha = CardConfig.cardAlpha + + TopAppBar( + title = { + Text( + text = stringResource( + when { + flashState.error.isNotEmpty() -> R.string.flash_failed + flashState.isCompleted -> R.string.flash_success + else -> R.string.kernel_flashing + } + ), + style = MaterialTheme.typography.titleLarge, + color = statusColor + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = cardColor.copy(alpha = cardAlpha), + scrolledContainerColor = cardColor.copy(alpha = cardAlpha) + ), + actions = { + IconButton(onClick = onSave) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(id = R.string.save_log), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/component/SlotSelectionDialog.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/component/SlotSelectionDialog.kt new file mode 100644 index 0000000..26da72c --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/component/SlotSelectionDialog.kt @@ -0,0 +1,258 @@ +package zako.zako.zako.zakoui.screen.kernelFlash.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SdStorage +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.sukisu.ultra.R + +/** + * 槽位选择对话框组件 + * 用于Kernel刷写时选择目标槽位 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SlotSelectionDialog( + show: Boolean, + onDismiss: () -> Unit, + onSlotSelected: (String) -> Unit +) { + var currentSlot by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + var selectedSlot by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + try { + currentSlot = getCurrentSlot() + // 设置默认选择为当前槽位 + selectedSlot = when (currentSlot) { + "a" -> "a" + "b" -> "b" + else -> null + } + errorMessage = null + } catch (e: Exception) { + errorMessage = e.message + currentSlot = null + } + } + + if (show) { + val cardColor = MaterialTheme.colorScheme.surfaceContainerHighest + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.select_slot_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Column( + modifier = Modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (errorMessage != null) { + Text( + text = "Error: $errorMessage", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } else { + Text( + text = stringResource( + id = R.string.current_slot, + currentSlot ?: "Unknown" + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(id = R.string.select_slot_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Horizontal arrangement for slot options with highlighted current slot + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val slotOptions = listOf( + ListOption( + titleText = stringResource(id = R.string.slot_a), + subtitleText = null, + icon = Icons.Filled.SdStorage + ), + ListOption( + titleText = stringResource(id = R.string.slot_b), + subtitleText = null, + icon = Icons.Filled.SdStorage + ) + ) + + slotOptions.forEachIndexed { index, option -> + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background( + color = if (selectedSlot == when(index) { + 0 -> "a" + else -> "b" + }) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) + } else { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } + ) + .clickable { + selectedSlot = when(index) { + 0 -> "a" + else -> "b" + } + } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = option.icon, + contentDescription = null, + tint = if (selectedSlot == when(index) { + 0 -> "a" + else -> "b" + }) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + ) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = option.titleText, + style = MaterialTheme.typography.titleMedium, + color = if (selectedSlot == when(index) { + 0 -> "a" + else -> "b" + }) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + } + ) + option.subtitleText?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = if (selectedSlot == when(index) { + 0 -> "a" + else -> "b" + }) { + MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + selectedSlot?.let { onSlotSelected(it) } + onDismiss() + }, + enabled = selectedSlot != null + ) { + Text( + text = stringResource(android.R.string.ok), + color = MaterialTheme.colorScheme.primary + ) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss + ) { + Text( + text = stringResource(android.R.string.cancel), + color = MaterialTheme.colorScheme.primary + ) + } + }, + containerColor = cardColor, + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 4.dp + ) + } +} + +// Data class for list options +data class ListOption( + val titleText: String, + val subtitleText: String?, + val icon: ImageVector +) + +// Utility function to get current slot +private fun getCurrentSlot(): String? { + return runCommandGetOutput(true, "getprop ro.boot.slot_suffix")?.let { + if (it.startsWith("_")) it.substring(1) else it + } +} + +private fun runCommandGetOutput(su: Boolean, cmd: String): String? { + return try { + val process = ProcessBuilder(if (su) "su" else "sh").start() + process.outputStream.bufferedWriter().use { writer -> + writer.write("$cmd\n") + writer.write("exit\n") + writer.flush() + } + process.inputStream.bufferedReader().use { reader -> + reader.readText().trim() + } + } catch (_: Exception) { + null + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/state/KernelFlashState.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/state/KernelFlashState.kt new file mode 100644 index 0000000..8cfa76b --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/kernelFlash/state/KernelFlashState.kt @@ -0,0 +1,524 @@ +package zako.zako.zako.zakoui.screen.kernelFlash.state + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.sukisu.ultra.R +import com.sukisu.ultra.network.RemoteToolsDownloader +import com.sukisu.ultra.ui.util.install +import com.sukisu.ultra.ui.util.rootAvailable +import com.sukisu.ultra.utils.AssetsUtil +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +data class FlashState( + val isFlashing: Boolean = false, + val isCompleted: Boolean = false, + val progress: Float = 0f, + val currentStep: String = "", + val logs: List = emptyList(), + val error: String = "" +) + +class HorizonKernelState { + private val _state = MutableStateFlow(FlashState()) + val state: StateFlow = _state.asStateFlow() + + fun updateProgress(progress: Float) { + _state.update { it.copy(progress = progress) } + } + + fun updateStep(step: String) { + _state.update { it.copy(currentStep = step) } + } + + fun addLog(log: String) { + _state.update { + it.copy(logs = it.logs + log) + } + } + + fun setError(error: String) { + _state.update { it.copy(error = error) } + } + + fun startFlashing() { + _state.update { + it.copy( + isFlashing = true, + isCompleted = false, + progress = 0f, + currentStep = "under preparation...", + logs = emptyList(), + error = "" + ) + } + } + + fun completeFlashing() { + _state.update { it.copy(isCompleted = true, progress = 1f) } + } + + fun reset() { + _state.value = FlashState() + } +} + +class HorizonKernelWorker( + private val context: Context, + private val state: HorizonKernelState, + private val slot: String? = null, + private val kpmPatchEnabled: Boolean = false, + private val kpmUndoPatch: Boolean = false +) : Thread() { + var uri: Uri? = null + private lateinit var filePath: String + private lateinit var binaryPath: String + private lateinit var workDir: String + + private var onFlashComplete: (() -> Unit)? = null + private var originalSlot: String? = null + private var downloaderJob: Job? = null + + fun setOnFlashCompleteListener(listener: () -> Unit) { + onFlashComplete = listener + } + + override fun run() { + state.startFlashing() + state.updateStep(context.getString(R.string.horizon_preparing)) + + filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}" + binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary" + workDir = "${context.filesDir.absolutePath}/work" + + try { + state.updateStep(context.getString(R.string.horizon_cleaning_files)) + state.updateProgress(0.1f) + cleanup() + + if (!rootAvailable()) { + state.setError(context.getString(R.string.root_required)) + return + } + + state.updateStep(context.getString(R.string.horizon_copying_files)) + state.updateProgress(0.2f) + copy() + + if (!File(filePath).exists()) { + state.setError(context.getString(R.string.horizon_copy_failed)) + return + } + + state.updateStep(context.getString(R.string.horizon_extracting_tool)) + state.updateProgress(0.4f) + getBinary() + + // KPM修补 + if (kpmPatchEnabled || kpmUndoPatch) { + state.updateStep(context.getString(R.string.kpm_preparing_tools)) + state.updateProgress(0.5f) + prepareKpmToolsWithDownload() + + state.updateStep( + if (kpmUndoPatch) context.getString(R.string.kpm_undoing_patch) + else context.getString(R.string.kpm_applying_patch) + ) + state.updateProgress(0.55f) + performKpmPatch() + } + + state.updateStep(context.getString(R.string.horizon_patching_script)) + state.updateProgress(0.6f) + patch() + + state.updateStep(context.getString(R.string.horizon_flashing)) + state.updateProgress(0.7f) + + val isAbDevice = isAbDevice() + + if (isAbDevice && slot != null) { + state.updateStep(context.getString(R.string.horizon_getting_original_slot)) + state.updateProgress(0.72f) + originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix") + + state.updateStep(context.getString(R.string.horizon_setting_target_slot)) + state.updateProgress(0.74f) + runCommand(true, "resetprop -n ro.boot.slot_suffix _$slot") + } + + flash() + + if (isAbDevice && !originalSlot.isNullOrEmpty()) { + state.updateStep(context.getString(R.string.horizon_restoring_original_slot)) + state.updateProgress(0.8f) + runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot") + } + + try { + install() + } catch (e: Exception) { + state.updateStep("ksud update skipped: ${e.message}") + } + + state.updateStep(context.getString(R.string.horizon_flash_complete_status)) + state.completeFlashing() + + (context as? Activity)?.runOnUiThread { + onFlashComplete?.invoke() + } + } catch (e: Exception) { + state.setError(e.message ?: context.getString(R.string.horizon_unknown_error)) + + if (isAbDevice() && !originalSlot.isNullOrEmpty()) { + state.updateStep(context.getString(R.string.horizon_restoring_original_slot)) + state.updateProgress(0.8f) + runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot") + } + } finally { + // 取消下载任务并清理 + downloaderJob?.cancel() + cleanupDownloader() + } + } + + private fun prepareKpmToolsWithDownload() { + try { + File(workDir).mkdirs() + val downloader = RemoteToolsDownloader(context, workDir) + + val progressListener = object : RemoteToolsDownloader.DownloadProgressListener { + override fun onProgress(fileName: String, progress: Int, total: Int) { + val percentage = if (total > 0) (progress * 100) / total else 0 + state.addLog("Downloading $fileName: $percentage% ($progress/$total bytes)") + } + + override fun onLog(message: String) { + state.addLog(message) + } + + override fun onError(fileName: String, error: String) { + state.addLog("Warning: $fileName - $error") + } + + override fun onSuccess(fileName: String, isRemote: Boolean) { + val source = if (isRemote) "remote" else "local" + state.addLog("✓ $fileName $source version prepared successfully") + } + } + + val downloadJob = CoroutineScope(Dispatchers.IO).launch { + downloader.downloadToolsAsync(progressListener) + } + + downloaderJob = downloadJob + + runBlocking { + downloadJob.join() + } + + val kptoolsPath = "$workDir/kptools" + val kpimgPath = "$workDir/kpimg" + + if (!File(kptoolsPath).exists()) { + throw IOException("kptools file preparation failed") + } + + if (!File(kpimgPath).exists()) { + throw IOException("kpimg file preparation failed") + } + + runCommand(true, "chmod a+rx $kptoolsPath") + state.addLog("KPM tools preparation completed, starting patch operation") + + } catch (_: CancellationException) { + state.addLog("KPM tools download cancelled") + throw IOException("Tool preparation process interrupted") + } catch (e: Exception) { + state.addLog("KPM tools preparation failed: ${e.message}") + + state.addLog("Attempting to use legacy local file extraction...") + try { + prepareKpmToolsLegacy() + state.addLog("Successfully used local backup files") + } catch (legacyException: Exception) { + state.addLog("Local file extraction also failed: ${legacyException.message}") + throw IOException("Unable to prepare KPM tool files: ${e.message}") + } + } + } + + private fun prepareKpmToolsLegacy() { + File(workDir).mkdirs() + + val kptoolsPath = "$workDir/kptools" + val kpimgPath = "$workDir/kpimg" + + AssetsUtil.exportFiles(context, "kptools", kptoolsPath) + if (!File(kptoolsPath).exists()) { + throw IOException("Local kptools file extraction failed") + } + + AssetsUtil.exportFiles(context, "kpimg", kpimgPath) + if (!File(kpimgPath).exists()) { + throw IOException("Local kpimg file extraction failed") + } + + runCommand(true, "chmod a+rx $kptoolsPath") + } + + private fun cleanupDownloader() { + try { + val downloader = RemoteToolsDownloader(context, workDir) + downloader.cleanup() + } catch (_: Exception) { + } + } + + /** + * 执行KPM修补操作 + */ + private fun performKpmPatch() { + try { + // 创建临时解压目录 + val extractDir = "$workDir/extracted" + File(extractDir).mkdirs() + + // 解压压缩包到临时目录 + val unzipResult = runCommand(true, "cd $extractDir && unzip -o \"$filePath\"") + if (unzipResult != 0) { + throw IOException(context.getString(R.string.kpm_extract_zip_failed)) + } + + // 查找Image文件 + val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f") + if (findImageResult.isBlank()) { + throw IOException(context.getString(R.string.kpm_image_file_not_found)) + } + + val imageFile = findImageResult.lines().first().trim() + val imageDir = File(imageFile).parent + val imageName = File(imageFile).name + + state.addLog(context.getString(R.string.kpm_found_image_file, imageFile)) + + // 复制KPM工具到Image文件所在目录 + runCommand(true, "cp $workDir/kptools $imageDir/") + runCommand(true, "cp $workDir/kpimg $imageDir/") + + // 执行KPM修补命令 + val patchCommand = if (kpmUndoPatch) { + "cd $imageDir && chmod a+rx kptools && ./kptools -u -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName" + } else { + "cd $imageDir && chmod a+rx kptools && ./kptools -p -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName" + } + + val patchResult = runCommand(true, patchCommand) + if (patchResult != 0) { + throw IOException( + if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_failed) + else context.getString(R.string.kpm_patch_failed) + ) + } + + state.addLog( + if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_success) + else context.getString(R.string.kpm_patch_success) + ) + + // 清理KPM工具文件 + runCommand(true, "rm -f $imageDir/kptools $imageDir/kpimg $imageDir/oImage") + + // 重新打包ZIP文件 + val originalFileName = File(filePath).name + val patchedFilePath = "$workDir/patched_$originalFileName" + + repackZipFolder(extractDir, patchedFilePath) + + // 替换原始文件 + runCommand(true, "mv \"$patchedFilePath\" \"$filePath\"") + + state.addLog(context.getString(R.string.kpm_file_repacked)) + + } catch (e: Exception) { + state.addLog(context.getString(R.string.kpm_patch_operation_failed, e.message)) + throw e + } finally { + // 清理临时文件 + runCommand(true, "rm -rf $workDir") + } + } + + private fun repackZipFolder(sourceDir: String, zipFilePath: String) { + try { + val buffer = ByteArray(1024) + val sourceFolder = File(sourceDir) + + FileOutputStream(zipFilePath).use { fos -> + ZipOutputStream(fos).use { zos -> + sourceFolder.walkTopDown().forEach { file -> + if (file.isFile) { + val relativePath = file.relativeTo(sourceFolder).path + val zipEntry = ZipEntry(relativePath) + zos.putNextEntry(zipEntry) + + file.inputStream().use { fis -> + var length: Int + while (fis.read(buffer).also { length = it } > 0) { + zos.write(buffer, 0, length) + } + } + + zos.closeEntry() + } + } + } + } + } catch (e: Exception) { + throw IOException("Failed to create zip file: ${e.message}", e) + } + } + + // 检查设备是否为AB分区设备 + private fun isAbDevice(): Boolean { + val abUpdate = runCommandGetOutput("getprop ro.build.ab_update") + if (!abUpdate.toBoolean()) return false + + val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix") + return slotSuffix.isNotEmpty() + } + + private fun cleanup() { + runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete") + runCommand(false, "rm -rf $workDir") + } + + private fun copy() { + uri?.let { safeUri -> + context.contentResolver.openInputStream(safeUri)?.use { input -> + FileOutputStream(File(filePath)).use { output -> + input.copyTo(output) + } + } + } + } + + private fun getBinary() { + runCommand(false, "unzip \"$filePath\" \"*/update-binary\" -d ${context.filesDir.absolutePath}") + if (!File(binaryPath).exists()) { + throw IOException("Failed to extract update-binary") + } + } + + @SuppressLint("StringFormatInvalid") + private fun patch() { + val kernelVersion = runCommandGetOutput("cat /proc/version") + val versionRegex = """\d+\.\d+\.\d+""".toRegex() + val version = kernelVersion.let { versionRegex.find(it) }?.value ?: "" + val toolName = if (version.isNotEmpty()) { + val parts = version.split('.') + if (parts.size >= 2) { + val major = parts[0].toIntOrNull() ?: 0 + val minor = parts[1].toIntOrNull() ?: 0 + if (major < 5 || (major == 5 && minor <= 10)) "5_10" else "5_15+" + } else { + "5_15+" + } + } else { + "5_15+" + } + val toolPath = "${context.filesDir.absolutePath}/mkbootfs" + AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath) + state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}") + runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath") + } + + private fun flash() { + val process = ProcessBuilder("su") + .redirectErrorStream(true) + .start() + + try { + process.outputStream.bufferedWriter().use { writer -> + writer.write("export POSTINSTALL=${context.filesDir.absolutePath}\n") + + // 写入槽位信息到临时文件 + slot?.let { selectedSlot -> + writer.write("echo \"$selectedSlot\" > ${context.filesDir.absolutePath}/bootslot\n") + } + + // 构建刷写命令 + val flashCommand = buildString { + append("sh $binaryPath 3 1 \"$filePath\"") + if (slot != null) { + append(" \"$(cat ${context.filesDir.absolutePath}/bootslot)\"") + } + append(" && touch ${context.filesDir.absolutePath}/done\n") + } + + writer.write(flashCommand) + writer.write("exit\n") + writer.flush() + } + + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + if (line.startsWith("ui_print")) { + val logMessage = line.removePrefix("ui_print").trim() + state.addLog(logMessage) + + when { + logMessage.contains("extracting", ignoreCase = true) -> { + state.updateProgress(0.75f) + } + logMessage.contains("installing", ignoreCase = true) -> { + state.updateProgress(0.85f) + } + logMessage.contains("complete", ignoreCase = true) -> { + state.updateProgress(0.95f) + } + } + } + } + } + } finally { + process.destroy() + } + + if (!File("${context.filesDir.absolutePath}/done").exists()) { + throw IOException(context.getString(R.string.flash_failed_message)) + } + } + + private fun runCommand(su: Boolean, cmd: String): Int { + val shell = if (su) "su" else "sh" + val process = Runtime.getRuntime().exec(arrayOf(shell, "-c", cmd)) + + return try { + process.waitFor() + } finally { + process.destroy() + } + } + + private fun runCommandGetOutput(cmd: String): String { + return Shell.cmd(cmd).exec().out.joinToString("\n").trim() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/MoreSettings.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/MoreSettings.kt new file mode 100644 index 0000000..1540d3a --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/MoreSettings.kt @@ -0,0 +1,757 @@ +package zako.zako.zako.zakoui.screen.moreSettings + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.theme.component.ImageEditorDialog +import com.sukisu.ultra.ui.component.KsuIsValid +import com.sukisu.ultra.ui.screen.SwitchItem +import com.sukisu.ultra.ui.theme.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import zako.zako.zako.zakoui.screen.moreSettings.component.ColorCircle +import zako.zako.zako.zakoui.screen.moreSettings.component.LanguageSelectionDialog +import zako.zako.zako.zakoui.screen.moreSettings.component.MoreSettingsDialogs +import zako.zako.zako.zakoui.screen.moreSettings.component.SettingItem +import zako.zako.zako.zakoui.screen.moreSettings.component.SettingsCard +import zako.zako.zako.zakoui.screen.moreSettings.component.SettingsDivider +import zako.zako.zako.zakoui.screen.moreSettings.component.SwitchSettingItem +import zako.zako.zako.zakoui.screen.moreSettings.component.UidScannerSection +import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState +import kotlin.math.roundToInt + +@SuppressLint("LocalContextConfigurationRead", "LocalContextResourcesRead", "ObsoleteSdkInt") +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun MoreSettingsScreen( + navigator: DestinationsNavigator +) { + // 顶部滚动行为 + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) } + val systemIsDark = isSystemInDarkTheme() + + // 创建设置状态管理器 + val settingsState = remember { MoreSettingsState(context, prefs, systemIsDark) } + val settingsHandlers = remember { MoreSettingsHandlers(context, prefs, settingsState) } + + // 图片选择器 + val pickImageLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + settingsState.selectedImageUri = it + settingsState.showImageEditor = true + } + } + + // 初始化设置 + LaunchedEffect(Unit) { + settingsHandlers.initializeSettings() + } + + // 显示图片编辑对话框 + if (settingsState.showImageEditor && settingsState.selectedImageUri != null) { + ImageEditorDialog( + imageUri = settingsState.selectedImageUri!!, + onDismiss = { + settingsState.showImageEditor = false + settingsState.selectedImageUri = null + }, + onConfirm = { transformedUri -> + settingsHandlers.handleCustomBackground(transformedUri) + settingsState.showImageEditor = false + settingsState.selectedImageUri = null + } + ) + } + + // 各种设置对话框 + MoreSettingsDialogs( + state = settingsState, + handlers = settingsHandlers + ) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.more_settings), + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + IconButton(onClick = { navigator.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha), + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha) + ), + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + scrollBehavior = scrollBehavior + ) + }, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 8.dp) + ) { + // 外观设置 + AppearanceSettings( + state = settingsState, + handlers = settingsHandlers, + pickImageLauncher = pickImageLauncher, + coroutineScope = coroutineScope + ) + + // 自定义设置 + CustomizationSettings( + state = settingsState, + handlers = settingsHandlers + ) + + // 高级设置 + KsuIsValid { + AdvancedSettings( + state = settingsState, + handlers = settingsHandlers + ) + } + } + } +} + +@Composable +private fun AppearanceSettings( + state: MoreSettingsState, + handlers: MoreSettingsHandlers, + pickImageLauncher: ActivityResultLauncher, + coroutineScope: CoroutineScope +) { + SettingsCard(title = stringResource(R.string.appearance_settings)) { + // 语言设置 + LanguageSetting(state = state) + + // 主题模式 + SettingItem( + icon = Icons.Default.DarkMode, + title = stringResource(R.string.theme_mode), + subtitle = state.themeOptions[state.themeMode], + onClick = { state.showThemeModeDialog = true } + ) + + // 动态颜色开关 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SwitchSettingItem( + icon = Icons.Filled.ColorLens, + title = stringResource(R.string.dynamic_color_title), + summary = stringResource(R.string.dynamic_color_summary), + checked = state.useDynamicColor, + onChange = handlers::handleDynamicColorChange + ) + } + + // 主题色选择 + AnimatedVisibility( + visible = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !state.useDynamicColor, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ThemeColorSelection(state = state) + } + + SettingsDivider() + + // DPI 设置 + DpiSettings(state = state, handlers = handlers) + + SettingsDivider() + + // 自定义背景设置 + CustomBackgroundSettings( + state = state, + handlers = handlers, + pickImageLauncher = pickImageLauncher, + coroutineScope = coroutineScope + ) + } +} + +@Composable +private fun CustomizationSettings( + state: MoreSettingsState, + handlers: MoreSettingsHandlers +) { + SettingsCard(title = stringResource(R.string.custom_settings)) { + // 图标切换 + SwitchSettingItem( + icon = Icons.Default.Android, + title = stringResource(R.string.icon_switch_title), + summary = stringResource(R.string.icon_switch_summary), + checked = state.useAltIcon, + onChange = handlers::handleIconChange + ) + + // 显示更多模块信息 + SwitchSettingItem( + icon = Icons.Filled.Info, + title = stringResource(R.string.show_more_module_info), + summary = stringResource(R.string.show_more_module_info_summary), + checked = state.showMoreModuleInfo, + onChange = handlers::handleShowMoreModuleInfoChange + ) + + // 简洁模式开关 + SwitchSettingItem( + icon = Icons.Filled.Brush, + title = stringResource(R.string.simple_mode), + summary = stringResource(R.string.simple_mode_summary), + checked = state.isSimpleMode, + onChange = handlers::handleSimpleModeChange + ) + + SwitchSettingItem( + icon = Icons.Filled.Brush, + title = stringResource(R.string.kernel_simple_kernel), + summary = stringResource(R.string.kernel_simple_kernel_summary), + checked = state.isKernelSimpleMode, + onChange = handlers::handleKernelSimpleModeChange + ) + + // 各种隐藏选项 + HideOptionsSettings(state = state, handlers = handlers) + } +} + +@Composable +private fun HideOptionsSettings( + state: MoreSettingsState, + handlers: MoreSettingsHandlers +) { + // 隐藏内核版本号 + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.hide_kernel_kernelsu_version), + summary = stringResource(R.string.hide_kernel_kernelsu_version_summary), + checked = state.isHideVersion, + onChange = handlers::handleHideVersionChange + ) + + // 隐藏模块数量等信息 + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.hide_other_info), + summary = stringResource(R.string.hide_other_info_summary), + checked = state.isHideOtherInfo, + onChange = handlers::handleHideOtherInfoChange + ) + + // SuSFS 状态信息 + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.hide_susfs_status), + summary = stringResource(R.string.hide_susfs_status_summary), + checked = state.isHideSusfsStatus, + onChange = handlers::handleHideSusfsStatusChange + ) + + // Zygisk 实现状态信息 + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.hide_zygisk_implement), + summary = stringResource(R.string.hide_zygisk_implement_summary), + checked = state.isHideZygiskImplement, + onChange = handlers::handleHideZygiskImplementChange + ) + + if (Natives.version >= Natives.MINIMAL_SUPPORTED_KPM) { + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.show_kpm_info), + summary = stringResource(R.string.show_kpm_info_summary), + checked = state.isShowKpmInfo, + onChange = handlers::handleShowKpmInfoChange + ) + } + + // 隐藏链接信息 + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.hide_link_card), + summary = stringResource(R.string.hide_link_card_summary), + checked = state.isHideLinkCard, + onChange = handlers::handleHideLinkCardChange + ) + + // 隐藏标签行 + SwitchSettingItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.hide_tag_card), + summary = stringResource(R.string.hide_tag_card_summary), + checked = state.isHideTagRow, + onChange = handlers::handleHideTagRowChange + ) +} + +@Composable +private fun AdvancedSettings( + state: MoreSettingsState, + handlers: MoreSettingsHandlers +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackBarHost = remember { SnackbarHostState() } + val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) } + + SettingsCard(title = stringResource(R.string.advanced_settings)) { + // SELinux 开关 + SwitchSettingItem( + icon = Icons.Filled.Security, + title = stringResource(R.string.selinux), + summary = if (state.selinuxEnabled) + stringResource(R.string.selinux_enabled) else + stringResource(R.string.selinux_disabled), + checked = state.selinuxEnabled, + onChange = handlers::handleSelinuxChange + ) + + var forceSignatureVerification by rememberSaveable { + mutableStateOf(prefs.getBoolean("force_signature_verification", false)) + } + + // 强制签名验证开关 + SwitchItem( + icon = Icons.Filled.Security, + title = stringResource(R.string.module_signature_verification), + summary = stringResource(R.string.module_signature_verification_summary), + checked = forceSignatureVerification, + onCheckedChange = { enabled -> + prefs.edit { putBoolean("force_signature_verification", enabled) } + forceSignatureVerification = enabled + } + ) + + // UID 扫描开关 + if (Natives.version >= Natives.MINIMAL_SUPPORTED_UID_SCANNER && Natives.version >= Natives.MINIMAL_NEW_IOCTL_KERNEL) { + UidScannerSection(prefs, snackBarHost, scope, context) + } + + // 动态管理器设置 + if (Natives.version >= Natives.MINIMAL_SUPPORTED_DYNAMIC_MANAGER && Natives.version >= Natives.MINIMAL_NEW_IOCTL_KERNEL) { + SettingItem( + icon = Icons.Filled.Security, + title = stringResource(R.string.dynamic_manager_title), + subtitle = if (state.isDynamicSignEnabled) { + stringResource(R.string.dynamic_manager_enabled_summary, state.dynamicSignSize) + } else { + stringResource(R.string.dynamic_manager_disabled) + }, + onClick = { state.showDynamicSignDialog = true } + ) + } + } +} + +@Composable +private fun ThemeColorSelection(state: MoreSettingsState) { + SettingItem( + icon = Icons.Default.Palette, + title = stringResource(R.string.theme_color), + subtitle = when (ThemeConfig.currentTheme) { + is ThemeColors.Green -> stringResource(R.string.color_green) + is ThemeColors.Purple -> stringResource(R.string.color_purple) + is ThemeColors.Orange -> stringResource(R.string.color_orange) + is ThemeColors.Pink -> stringResource(R.string.color_pink) + is ThemeColors.Gray -> stringResource(R.string.color_gray) + is ThemeColors.Yellow -> stringResource(R.string.color_yellow) + else -> stringResource(R.string.color_default) + }, + onClick = { state.showThemeColorDialog = true }, + trailingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp) + ) { + val theme = ThemeConfig.currentTheme + val isDark = isSystemInDarkTheme() + + ColorCircle( + color = if (isDark) theme.primaryDark else theme.primaryLight, + isSelected = false, + modifier = Modifier.padding(horizontal = 2.dp) + ) + ColorCircle( + color = if (isDark) theme.secondaryDark else theme.secondaryLight, + isSelected = false, + modifier = Modifier.padding(horizontal = 2.dp) + ) + ColorCircle( + color = if (isDark) theme.tertiaryDark else theme.tertiaryLight, + isSelected = false, + modifier = Modifier.padding(horizontal = 2.dp) + ) + } + } + ) +} + +@Composable +private fun DpiSettings( + state: MoreSettingsState, + handlers: MoreSettingsHandlers +) { + SettingItem( + icon = Icons.Default.FormatSize, + title = stringResource(R.string.app_dpi_title), + subtitle = stringResource(R.string.app_dpi_summary), + onClick = {}, + trailingContent = { + Text( + text = handlers.getDpiFriendlyName(state.tempDpi), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + ) + + // DPI 滑动条和控制 + DpiSliderControls(state = state, handlers = handlers) +} + +@Composable +private fun DpiSliderControls( + state: MoreSettingsState, + handlers: MoreSettingsHandlers +) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val sliderValue by animateFloatAsState( + targetValue = state.tempDpi.toFloat(), + label = "DPI Slider Animation" + ) + + Slider( + value = sliderValue, + onValueChange = { newValue -> + state.tempDpi = newValue.toInt() + state.isDpiCustom = !state.dpiPresets.containsValue(state.tempDpi) + }, + valueRange = 160f..600f, + steps = 11, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + + // DPI 预设按钮行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + state.dpiPresets.forEach { (name, dpi) -> + val isSelected = state.tempDpi == dpi + val buttonColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant + + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(buttonColor) + .clickable { + state.tempDpi = dpi + state.isDpiCustom = false + } + .padding(vertical = 8.dp, horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = name, + style = MaterialTheme.typography.labelMedium, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Text( + text = if (state.isDpiCustom) + "${stringResource(R.string.dpi_size_custom)}: ${state.tempDpi}" + else + "${handlers.getDpiFriendlyName(state.tempDpi)}: ${state.tempDpi}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp) + ) + + Button( + onClick = { state.showDpiConfirmDialog = true }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + enabled = state.tempDpi != state.currentDpi + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.dpi_apply_settings)) + } + } +} + +@Composable +private fun CustomBackgroundSettings( + state: MoreSettingsState, + handlers: MoreSettingsHandlers, + pickImageLauncher: ActivityResultLauncher, + coroutineScope: CoroutineScope +) { + // 自定义背景开关 + SwitchSettingItem( + icon = Icons.Filled.Wallpaper, + title = stringResource(id = R.string.settings_custom_background), + summary = stringResource(id = R.string.settings_custom_background_summary), + checked = state.isCustomBackgroundEnabled, + onChange = { isChecked -> + if (isChecked) { + pickImageLauncher.launch("image/*") + } else { + handlers.handleRemoveCustomBackground() + } + } + ) + + // 透明度和亮度调节 + AnimatedVisibility( + visible = ThemeConfig.customBackgroundUri != null, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + BackgroundAdjustmentControls( + state = state, + handlers = handlers, + coroutineScope = coroutineScope + ) + } +} + +@Composable +private fun BackgroundAdjustmentControls( + state: MoreSettingsState, + handlers: MoreSettingsHandlers, + coroutineScope: CoroutineScope +) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + // 透明度滑动条 + AlphaSlider(state = state, handlers = handlers, coroutineScope = coroutineScope) + + // 亮度调节滑动条 + DimSlider(state = state, handlers = handlers, coroutineScope = coroutineScope) + } +} + +@Composable +private fun AlphaSlider( + state: MoreSettingsState, + handlers: MoreSettingsHandlers, + coroutineScope: CoroutineScope +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Icon( + Icons.Filled.Opacity, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.settings_card_alpha), + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${(state.cardAlpha * 100).roundToInt()}%", + style = MaterialTheme.typography.labelMedium, + ) + } + + val alphaSliderValue by animateFloatAsState( + targetValue = state.cardAlpha, + label = "Alpha Slider Animation" + ) + + Slider( + value = alphaSliderValue, + onValueChange = { newValue -> + handlers.handleCardAlphaChange(newValue) + }, + onValueChangeFinished = { + coroutineScope.launch(Dispatchers.IO) { + saveCardConfig(handlers.context) + } + }, + valueRange = 0f..1f, + steps = 20, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) +} + +@Composable +private fun DimSlider( + state: MoreSettingsState, + handlers: MoreSettingsHandlers, + coroutineScope: CoroutineScope +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) { + Icon( + Icons.Filled.LightMode, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.settings_card_dim), + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${(state.cardDim * 100).roundToInt()}%", + style = MaterialTheme.typography.labelMedium, + ) + } + + val dimSliderValue by animateFloatAsState( + targetValue = state.cardDim, + label = "Dim Slider Animation" + ) + + Slider( + value = dimSliderValue, + onValueChange = { newValue -> + handlers.handleCardDimChange(newValue) + }, + onValueChangeFinished = { + coroutineScope.launch(Dispatchers.IO) { + saveCardConfig(handlers.context) + } + }, + valueRange = 0f..1f, + steps = 20, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) +} + +fun saveCardConfig(context: Context) { + CardConfig.save(context) +} + +@Composable +private fun LanguageSetting(state: MoreSettingsState) { + val context = LocalContext.current + val language = stringResource(id = R.string.settings_language) + + // Compute display name based on current app locale + val currentLanguageDisplay = remember(state.currentAppLocale) { + val locale = state.currentAppLocale + if (locale != null) { + locale.getDisplayName(locale) + } else { + context.getString(R.string.language_system_default) + } + } + + SettingItem( + icon = Icons.Filled.Translate, + title = language, + subtitle = currentLanguageDisplay, + onClick = { state.showLanguageDialog = true } + ) + + // Language Selection Dialog + if (state.showLanguageDialog) { + LanguageSelectionDialog( + onLanguageSelected = { newLocale -> + // Update local state immediately + state.currentAppLocale = LocaleHelper.getCurrentAppLocale(context) + // Apply locale change immediately for Android < 13 + LocaleHelper.restartActivity(context) + }, + onDismiss = { state.showLanguageDialog = false } + ) + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/MoreSettingsHandlers.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/MoreSettingsHandlers.kt new file mode 100644 index 0000000..b5b9921 --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/MoreSettingsHandlers.kt @@ -0,0 +1,459 @@ +package zako.zako.zako.zakoui.screen.moreSettings + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.net.Uri +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Scanner +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.ConfirmResult +import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.screen.SettingItem +import com.sukisu.ultra.ui.screen.SwitchItem +import com.sukisu.ultra.ui.theme.* +import com.sukisu.ultra.ui.util.* +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState +import zako.zako.zako.zakoui.screen.moreSettings.util.toggleLauncherIcon + +/** + * 更多设置处理器 + */ +class MoreSettingsHandlers( + val context: Context, + private val prefs: SharedPreferences, + private val state: MoreSettingsState +) { + + /** + * 初始化设置 + */ + fun initializeSettings() { + // 加载设置 + CardConfig.load(context) + state.cardAlpha = CardConfig.cardAlpha + state.cardDim = CardConfig.cardDim + state.isCustomBackgroundEnabled = ThemeConfig.customBackgroundUri != null + + // 设置主题模式 + state.themeMode = when (ThemeConfig.forceDarkMode) { + true -> 2 + false -> 1 + null -> 0 + } + + // 确保卡片样式跟随主题模式 + when (state.themeMode) { + 2 -> { // 深色 + CardConfig.isUserDarkModeEnabled = true + CardConfig.isUserLightModeEnabled = false + } + 1 -> { // 浅色 + CardConfig.isUserDarkModeEnabled = false + CardConfig.isUserLightModeEnabled = true + } + 0 -> { // 跟随系统 + CardConfig.isUserDarkModeEnabled = false + CardConfig.isUserLightModeEnabled = false + } + } + + // 如果启用了系统跟随且系统是深色模式,应用深色模式默认值 + if (state.themeMode == 0 && state.systemIsDark) { + CardConfig.setThemeDefaults(true) + } + + state.currentDpi = prefs.getInt("app_dpi", state.systemDpi) + state.tempDpi = state.currentDpi + + CardConfig.save(context) + + // 初始化 SELinux 状态 + state.selinuxEnabled = Shell.cmd("getenforce").exec().out.firstOrNull() == "Enforcing" + + // 初始化动态管理器配置 + state.dynamicSignConfig = Natives.getDynamicManager() + state.dynamicSignConfig?.let { config -> + if (config.isValid()) { + state.isDynamicSignEnabled = true + state.dynamicSignSize = config.size.toString() + state.dynamicSignHash = config.hash + } + } + } + + /** + * 处理主题模式变更 + */ + fun handleThemeModeChange(index: Int) { + state.themeMode = index + val newThemeMode = when (index) { + 0 -> null // 跟随系统 + 1 -> false // 浅色 + 2 -> true // 深色 + else -> null + } + context.saveThemeMode(newThemeMode) + ThemeConfig.updateTheme(darkMode = newThemeMode) + + when (index) { + 2 -> { // 深色 + ThemeConfig.updateTheme(darkMode = true) + CardConfig.updateThemePreference(darkMode = true, lightMode = false) + CardConfig.setThemeDefaults(true) + CardConfig.save(context) + } + 1 -> { // 浅色 + ThemeConfig.updateTheme(darkMode = false) + CardConfig.updateThemePreference(darkMode = false, lightMode = true) + CardConfig.setThemeDefaults(false) + CardConfig.save(context) + } + 0 -> { // 跟随系统 + ThemeConfig.updateTheme(darkMode = null) + CardConfig.updateThemePreference(darkMode = null, lightMode = null) + val isNightModeActive = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + CardConfig.setThemeDefaults(isNightModeActive) + CardConfig.save(context) + } + } + } + + /** + * 处理主题色变更 + */ + fun handleThemeColorChange(theme: ThemeColors) { + context.saveThemeColors(when (theme) { + ThemeColors.Green -> "green" + ThemeColors.Purple -> "purple" + ThemeColors.Orange -> "orange" + ThemeColors.Pink -> "pink" + ThemeColors.Gray -> "gray" + ThemeColors.Yellow -> "yellow" + else -> "default" + }) + ThemeConfig.updateTheme(theme = theme) + } + + /** + * 处理动态颜色变更 + */ + fun handleDynamicColorChange(enabled: Boolean) { + state.useDynamicColor = enabled + context.saveDynamicColorState(enabled) + ThemeConfig.updateTheme(dynamicColor = enabled) + } + + /** + * 获取DPI大小友好名称 + */ + @Composable + fun getDpiFriendlyName(dpi: Int): String { + return when (dpi) { + 240 -> stringResource(R.string.dpi_size_small) + 320 -> stringResource(R.string.dpi_size_medium) + 420 -> stringResource(R.string.dpi_size_large) + 560 -> stringResource(R.string.dpi_size_extra_large) + else -> stringResource(R.string.dpi_size_custom) + } + } + + /** + * 应用 DPI 设置 + */ + fun handleDpiApply() { + if (state.tempDpi != state.currentDpi) { + prefs.edit { + putInt("app_dpi", state.tempDpi) + } + + state.currentDpi = state.tempDpi + Toast.makeText( + context, + context.getString(R.string.dpi_applied_success, state.tempDpi), + Toast.LENGTH_SHORT + ).show() + + val restartIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + restartIntent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(restartIntent) + + state.showDpiConfirmDialog = false + } + } + + /** + * 处理自定义背景 + */ + fun handleCustomBackground(transformedUri: Uri) { + context.saveAndApplyCustomBackground(transformedUri) + state.isCustomBackgroundEnabled = true + CardConfig.cardElevation = 0.dp + CardConfig.isCustomBackgroundEnabled = true + saveCardConfig(context) + + Toast.makeText( + context, + context.getString(R.string.background_set_success), + Toast.LENGTH_SHORT + ).show() + } + + /** + * 处理移除自定义背景 + */ + fun handleRemoveCustomBackground() { + context.saveCustomBackground(null) + state.isCustomBackgroundEnabled = false + CardConfig.cardAlpha = 1f + CardConfig.cardDim = 0f + CardConfig.isCustomAlphaSet = false + CardConfig.isCustomDimSet = false + CardConfig.isCustomBackgroundEnabled = false + saveCardConfig(context) + ThemeConfig.preventBackgroundRefresh = false + + context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit { + putBoolean("prevent_background_refresh", false) + } + + Toast.makeText( + context, + context.getString(R.string.background_removed), + Toast.LENGTH_SHORT + ).show() + } + + /** + * 处理卡片透明度变更 + */ + fun handleCardAlphaChange(newValue: Float) { + state.cardAlpha = newValue + CardConfig.cardAlpha = newValue + CardConfig.isCustomAlphaSet = true + prefs.edit { + putBoolean("is_custom_alpha_set", true) + putFloat("card_alpha", newValue) + } + } + + /** + * 处理卡片亮度变更 + */ + fun handleCardDimChange(newValue: Float) { + state.cardDim = newValue + CardConfig.cardDim = newValue + CardConfig.isCustomDimSet = true + prefs.edit { + putBoolean("is_custom_dim_set", true) + putFloat("card_dim", newValue) + } + } + + /** + * 处理图标变更 + */ + fun handleIconChange(newValue: Boolean) { + prefs.edit { putBoolean("use_alt_icon", newValue) } + state.useAltIcon = newValue + toggleLauncherIcon(context, newValue) + Toast.makeText(context, context.getString(R.string.icon_switched), Toast.LENGTH_SHORT).show() + } + + /** + * 处理简洁模式变更 + */ + fun handleSimpleModeChange(newValue: Boolean) { + prefs.edit { putBoolean("is_simple_mode", newValue) } + state.isSimpleMode = newValue + } + + /** + * 处理内核简洁模式变更 + */ + fun handleKernelSimpleModeChange(newValue: Boolean) { + prefs.edit { putBoolean("is_kernel_simple_mode", newValue) } + state.isKernelSimpleMode = newValue + } + + /** + * 处理隐藏版本变更 + */ + fun handleHideVersionChange(newValue: Boolean) { + prefs.edit { putBoolean("is_hide_version", newValue) } + state.isHideVersion = newValue + } + + /** + * 处理隐藏其他信息变更 + */ + fun handleHideOtherInfoChange(newValue: Boolean) { + prefs.edit { putBoolean("is_hide_other_info", newValue) } + state.isHideOtherInfo = newValue + } + + /** + * 处理显示KPM信息变更 + */ + fun handleShowKpmInfoChange(newValue: Boolean) { + prefs.edit { putBoolean("show_kpm_info", newValue) } + state.isShowKpmInfo = newValue + } + + /** + * 处理隐藏SuSFS状态变更 + */ + fun handleHideSusfsStatusChange(newValue: Boolean) { + prefs.edit { putBoolean("is_hide_susfs_status", newValue) } + state.isHideSusfsStatus = newValue + } + + /** + * 处理隐藏Zygisk实现变更 + */ + fun handleHideZygiskImplementChange(newValue: Boolean) { + prefs.edit { putBoolean("is_hide_zygisk_Implement", newValue) } + state.isHideZygiskImplement = newValue + } + + /** + * 处理隐藏链接卡片变更 + */ + fun handleHideLinkCardChange(newValue: Boolean) { + prefs.edit { putBoolean("is_hide_link_card", newValue) } + state.isHideLinkCard = newValue + } + + /** + * 处理隐藏标签行变更 + */ + fun handleHideTagRowChange(newValue: Boolean) { + prefs.edit { putBoolean("is_hide_tag_row", newValue) } + state.isHideTagRow = newValue + } + + /** + * 处理显示更多模块信息变更 + */ + fun handleShowMoreModuleInfoChange(newValue: Boolean) { + prefs.edit { putBoolean("show_more_module_info", newValue) } + state.showMoreModuleInfo = newValue + } + + /** + * 处理SELinux变更 + */ + fun handleSelinuxChange(enabled: Boolean) { + val command = if (enabled) "setenforce 1" else "setenforce 0" + Shell.getShell().newJob().add(command).exec().let { result -> + if (result.isSuccess) { + state.selinuxEnabled = enabled + val message = if (enabled) + context.getString(R.string.selinux_enabled_toast) + else + context.getString(R.string.selinux_disabled_toast) + + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText( + context, + context.getString(R.string.selinux_change_failed), + Toast.LENGTH_SHORT + ).show() + } + } + } + + /** + * 处理动态管理器配置 + */ + fun handleDynamicManagerConfig(enabled: Boolean, size: String, hash: String) { + if (enabled) { + val parsedSize = parseDynamicSignSize(size) + if (parsedSize != null && parsedSize > 0 && hash.length == 64) { + val success = Natives.setDynamicManager(parsedSize, hash) + if (success) { + state.dynamicSignConfig = Natives.DynamicManagerConfig(parsedSize, hash) + state.isDynamicSignEnabled = true + state.dynamicSignSize = size + state.dynamicSignHash = hash + Toast.makeText( + context, + context.getString(R.string.dynamic_manager_set_success), + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + context, + context.getString(R.string.dynamic_manager_set_failed), + Toast.LENGTH_SHORT + ).show() + } + } else { + Toast.makeText( + context, + context.getString(R.string.invalid_sign_config), + Toast.LENGTH_SHORT + ).show() + } + } else { + val success = Natives.clearDynamicManager() + if (success) { + state.dynamicSignConfig = null + state.isDynamicSignEnabled = false + state.dynamicSignSize = "" + state.dynamicSignHash = "" + Toast.makeText( + context, + context.getString(R.string.dynamic_manager_disabled_success), + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + context, + context.getString(R.string.dynamic_manager_clear_failed), + Toast.LENGTH_SHORT + ).show() + } + } + } + + /** + * 解析动态签名大小 + */ + private fun parseDynamicSignSize(input: String): Int? { + return try { + when { + input.startsWith("0x", true) -> input.substring(2).toInt(16) + else -> input.toInt() + } + } catch (_: NumberFormatException) { + null + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/component/MoreSettingsComponents.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/component/MoreSettingsComponents.kt new file mode 100644 index 0000000..3c182c1 --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/component/MoreSettingsComponents.kt @@ -0,0 +1,201 @@ +package zako.zako.zako.zakoui.screen.moreSettings.component + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sukisu.ultra.ui.theme.* + +private val SETTINGS_GROUP_SPACING = 16.dp + +@Composable +fun SettingsCard( + title: String, + icon: ImageVector? = null, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = SETTINGS_GROUP_SPACING), + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh), + elevation = getCardElevation(), + shape = MaterialTheme.shapes.medium + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 16.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + } + content() + } + } +} + +@Composable +fun SettingItem( + icon: ImageVector, + title: String, + subtitle: String? = null, + onClick: () -> Unit, + iconTint: Color = MaterialTheme.colorScheme.primary, + trailingContent: @Composable (() -> Unit)? = { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 5.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Visible + ) + if (subtitle != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Visible + ) + } + } + + trailingContent?.invoke() + } +} + +@Composable +fun SwitchSettingItem( + icon: ImageVector, + title: String, + summary: String? = null, + checked: Boolean, + onChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onChange(!checked) } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + lineHeight = 20.sp, + ) + if (summary != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 16.sp, + ) + } + } + + Switch( + checked = checked, + onCheckedChange = onChange + ) + } +} + +@Composable +fun SettingsDivider() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp) + ) +} + +@Composable +fun ColorCircle( + color: Color, + isSelected: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(20.dp) + .clip(CircleShape) + .background(color) + .then( + if (isSelected) { + Modifier.border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ) + } else { + Modifier + } + ) + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/component/MoreSettingsDialogs.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/component/MoreSettingsDialogs.kt new file mode 100644 index 0000000..cebfd8b --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/component/MoreSettingsDialogs.kt @@ -0,0 +1,620 @@ +package zako.zako.zako.zakoui.screen.moreSettings.component + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Scanner +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.list.ListDialog +import com.maxkeppeler.sheets.list.models.ListOption +import com.maxkeppeler.sheets.list.models.ListSelection +import com.sukisu.ultra.Natives +import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.ConfirmResult +import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.screen.SwitchItem +import com.sukisu.ultra.ui.theme.* +import com.sukisu.ultra.ui.util.cleanRuntimeEnvironment +import com.sukisu.ultra.ui.util.getUidMultiUserScan +import com.sukisu.ultra.ui.util.readUidScannerFile +import com.sukisu.ultra.ui.util.setUidAutoScan +import com.sukisu.ultra.ui.util.setUidMultiUserScan +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import zako.zako.zako.zakoui.screen.moreSettings.MoreSettingsHandlers +import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState + +@Composable +fun MoreSettingsDialogs( + state: MoreSettingsState, + handlers: MoreSettingsHandlers +) { + // 主题模式选择对话框 + if (state.showThemeModeDialog) { + SingleChoiceDialog( + title = stringResource(R.string.theme_mode), + options = state.themeOptions, + selectedIndex = state.themeMode, + onOptionSelected = { index -> + handlers.handleThemeModeChange(index) + }, + onDismiss = { state.showThemeModeDialog = false } + ) + } + + // DPI 设置确认对话框 + if (state.showDpiConfirmDialog) { + ConfirmDialog( + title = stringResource(R.string.dpi_confirm_title), + message = stringResource(R.string.dpi_confirm_message, state.currentDpi, state.tempDpi), + summaryText = stringResource(R.string.dpi_confirm_summary), + confirmText = stringResource(R.string.confirm), + dismissText = stringResource(R.string.cancel), + onConfirm = { handlers.handleDpiApply() }, + onDismiss = { + state.showDpiConfirmDialog = false + state.tempDpi = state.currentDpi + } + ) + } + + // 主题色选择对话框 + if (state.showThemeColorDialog) { + ThemeColorDialog( + onColorSelected = { theme -> + handlers.handleThemeColorChange(theme) + state.showThemeColorDialog = false + }, + onDismiss = { state.showThemeColorDialog = false } + ) + } + + // 动态管理器配置对话框 + if (state.showDynamicSignDialog) { + DynamicManagerDialog( + state = state, + onConfirm = { enabled, size, hash -> + handlers.handleDynamicManagerConfig(enabled, size, hash) + state.showDynamicSignDialog = false + }, + onDismiss = { state.showDynamicSignDialog = false } + ) + } +} + +@Composable +fun SingleChoiceDialog( + title: String, + options: List, + selectedIndex: Int, + onOptionSelected: (Int) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + options.forEachIndexed { index, option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onOptionSelected(index) + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedIndex == index, + onClick = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(option) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +fun ConfirmDialog( + title: String, + message: String, + summaryText: String? = null, + confirmText: String = stringResource(R.string.confirm), + dismissText: String = stringResource(R.string.cancel), + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + Text(message) + if (summaryText != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + summaryText, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(confirmText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguageSelectionDialog( + onLanguageSelected: (String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + // Check if should use system language settings + if (LocaleHelper.useSystemLanguageSettings) { + // Android 13+ - Jump to system settings + LocaleHelper.launchSystemLanguageSettings(context) + onDismiss() + } else { + // Android < 13 - Show app language selector + // Dynamically detect supported locales from resources + val supportedLocales = remember { + val locales = mutableListOf() + + // Add system default first + locales.add(java.util.Locale.ROOT) // This will represent "System Default" + + // Dynamically detect available locales by checking resource directories + val resourceDirs = listOf( + "ar", "bg", "de", "fa", "fr", "hu", "in", "it", + "ja", "ko", "pl", "pt-rBR", "ru", "th", "tr", + "uk", "vi", "zh-rCN", "zh-rTW" + ) + + resourceDirs.forEach { dir -> + try { + val locale = when { + dir.contains("-r") -> { + val parts = dir.split("-r") + java.util.Locale.Builder() + .setLanguage(parts[0]) + .setRegion(parts[1]) + .build() + } + else -> java.util.Locale.Builder() + .setLanguage(dir) + .build() + } + + // Test if this locale has translated resources + val config = android.content.res.Configuration() + config.setLocale(locale) + val localizedContext = context.createConfigurationContext(config) + + // Try to get a translated string to verify the locale is supported + val testString = localizedContext.getString(R.string.settings_language) + val defaultString = context.getString(R.string.settings_language) + + // If the string is different or it's English, it's supported + if (testString != defaultString || locale.language == "en") { + locales.add(locale) + } + } catch (_: Exception) { + // Skip unsupported locales + } + } + + // Sort by display name + val sortedLocales = locales.drop(1).sortedBy { it.getDisplayName(it) } + mutableListOf().apply { + add(locales.first()) // System default first + addAll(sortedLocales) + } + } + + val allOptions = supportedLocales.map { locale -> + val tag = if (locale == java.util.Locale.ROOT) { + "system" + } else if (locale.country.isEmpty()) { + locale.language + } else { + "${locale.language}_${locale.country}" + } + + val displayName = if (locale == java.util.Locale.ROOT) { + context.getString(R.string.language_system_default) + } else { + locale.getDisplayName(locale) + } + + tag to displayName + } + + val currentLocale = prefs.getString("app_locale", "system") ?: "system" + val options = allOptions.map { (tag, displayName) -> + ListOption( + titleText = displayName, + selected = currentLocale == tag + ) + } + + var selectedIndex by remember { + mutableIntStateOf(allOptions.indexOfFirst { (tag, _) -> currentLocale == tag }) + } + + ListDialog( + state = rememberUseCaseState( + visible = true, + onFinishedRequest = { + if (selectedIndex >= 0 && selectedIndex < allOptions.size) { + val newLocale = allOptions[selectedIndex].first + prefs.edit { putString("app_locale", newLocale) } + onLanguageSelected(newLocale) + } + onDismiss() + }, + onCloseRequest = { + onDismiss() + } + ), + header = Header.Default( + title = stringResource(R.string.settings_language), + ), + selection = ListSelection.Single( + showRadioButtons = true, + options = options + ) { index, _ -> + selectedIndex = index + } + ) + } +} +@Composable +fun ThemeColorDialog( + onColorSelected: (ThemeColors) -> Unit, + onDismiss: () -> Unit +) { + val themeColorOptions = listOf( + stringResource(R.string.color_default) to ThemeColors.Default, + stringResource(R.string.color_green) to ThemeColors.Green, + stringResource(R.string.color_purple) to ThemeColors.Purple, + stringResource(R.string.color_orange) to ThemeColors.Orange, + stringResource(R.string.color_pink) to ThemeColors.Pink, + stringResource(R.string.color_gray) to ThemeColors.Gray, + stringResource(R.string.color_yellow) to ThemeColors.Yellow + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.choose_theme_color)) }, + text = { + Column { + themeColorOptions.forEach { (name, theme) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onColorSelected(theme) } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val isDark = isSystemInDarkTheme() + Box( + modifier = Modifier.padding(end = 12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorCircle( + color = if (isDark) theme.primaryDark else theme.primaryLight, + isSelected = false, + modifier = Modifier.padding(horizontal = 2.dp) + ) + ColorCircle( + color = if (isDark) theme.secondaryDark else theme.secondaryLight, + isSelected = false, + modifier = Modifier.padding(horizontal = 2.dp) + ) + ColorCircle( + color = if (isDark) theme.tertiaryDark else theme.tertiaryLight, + isSelected = false, + modifier = Modifier.padding(horizontal = 2.dp) + ) + } + } + Text(name) + Spacer(modifier = Modifier.weight(1f)) + // 当前选中的主题显示选中标记 + if (ThemeConfig.currentTheme::class == theme::class) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = onDismiss + ) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +fun DynamicManagerDialog( + state: MoreSettingsState, + onConfirm: (Boolean, String, String) -> Unit, + onDismiss: () -> Unit +) { + var localEnabled by remember { mutableStateOf(state.isDynamicSignEnabled) } + var localSize by remember { mutableStateOf(state.dynamicSignSize) } + var localHash by remember { mutableStateOf(state.dynamicSignHash) } + + fun parseDynamicSignSize(input: String): Int? { + return try { + when { + input.startsWith("0x", true) -> input.substring(2).toInt(16) + else -> input.toInt() + } + } catch (_: NumberFormatException) { + null + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dynamic_manager_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + // 启用开关 + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { localEnabled = !localEnabled } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = localEnabled, + onCheckedChange = { localEnabled = it } + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.enable_dynamic_manager)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 签名大小输入 + OutlinedTextField( + value = localSize, + onValueChange = { input -> + val isValid = when { + input.isEmpty() -> true + input.matches(Regex("^\\d+$")) -> true + input.matches(Regex("^0[xX][0-9a-fA-F]*$")) -> true + else -> false + } + if (isValid) { + localSize = input + } + }, + label = { Text(stringResource(R.string.signature_size)) }, + enabled = localEnabled, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 签名哈希输入 + OutlinedTextField( + value = localHash, + onValueChange = { hash -> + if (hash.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { + localHash = hash + } + }, + label = { Text(stringResource(R.string.signature_hash)) }, + enabled = localEnabled, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + supportingText = { + Text(stringResource(R.string.hash_must_be_64_chars)) + }, + isError = localEnabled && localHash.isNotEmpty() && localHash.length != 64 + ) + } + }, + confirmButton = { + Button( + onClick = { onConfirm(localEnabled, localSize, localHash) }, + enabled = if (localEnabled) { + parseDynamicSignSize(localSize)?.let { it > 0 } == true && + localHash.length == 64 + } else true + ) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +fun UidScannerSection( + prefs: SharedPreferences, + snackBarHost: SnackbarHostState, + scope: CoroutineScope, + context: Context +) { + if (Natives.version < Natives.MINIMAL_SUPPORTED_UID_SCANNER) return + + val realAuto = Natives.isUidScannerEnabled() + val realMulti = getUidMultiUserScan() + + var autoOn by remember { mutableStateOf(realAuto) } + var multiOn by remember { mutableStateOf(realMulti) } + + LaunchedEffect(Unit) { + autoOn = realAuto + multiOn = realMulti + prefs.edit { + putBoolean("uid_auto_scan", autoOn) + putBoolean("uid_multi_user_scan", multiOn) + } + } + + SwitchItem( + icon = Icons.Filled.Scanner, + title = stringResource(R.string.uid_auto_scan_title), + summary = stringResource(R.string.uid_auto_scan_summary), + checked = autoOn, + onCheckedChange = { target -> + autoOn = target + if (!target) multiOn = false + + scope.launch(Dispatchers.IO) { + setUidAutoScan(target) + val actual = Natives.isUidScannerEnabled() || readUidScannerFile() + withContext(Dispatchers.Main) { + autoOn = actual + if (!actual) multiOn = false + prefs.edit { + putBoolean("uid_auto_scan", actual) + putBoolean("uid_multi_user_scan", multiOn) + } + if (actual != target) { + snackBarHost.showSnackbar( + context.getString(R.string.uid_scanner_setting_failed) + ) + } + } + } + } + ) + + AnimatedVisibility( + visible = autoOn, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + SwitchItem( + icon = Icons.Filled.Groups, + title = stringResource(R.string.uid_multi_user_scan_title), + summary = stringResource(R.string.uid_multi_user_scan_summary), + checked = multiOn, + onCheckedChange = { target -> + scope.launch(Dispatchers.IO) { + val ok = setUidMultiUserScan(target) + withContext(Dispatchers.Main) { + if (ok) { + multiOn = target + prefs.edit { putBoolean("uid_multi_user_scan", target) } + } else { + snackBarHost.showSnackbar( + context.getString(R.string.uid_scanner_setting_failed) + ) + } + } + } + } + ) + } + + AnimatedVisibility( + visible = autoOn, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + val confirmDialog = rememberConfirmDialog() + com.sukisu.ultra.ui.screen.SettingItem( + icon = Icons.Filled.CleaningServices, + title = stringResource(R.string.clean_runtime_environment), + summary = stringResource(R.string.clean_runtime_environment_summary), + onClick = { + scope.launch { + if (confirmDialog.awaitConfirm( + title = context.getString(R.string.clean_runtime_environment), + content = context.getString(R.string.clean_runtime_environment_confirm) + ) == ConfirmResult.Confirmed + ) { + if (cleanRuntimeEnvironment()) { + autoOn = false + multiOn = false + prefs.edit { + putBoolean("uid_auto_scan", false) + putBoolean("uid_multi_user_scan", false) + } + Natives.setUidScannerEnabled(false) + snackBarHost.showSnackbar( + context.getString(R.string.clean_runtime_environment_success) + ) + } else { + snackBarHost.showSnackbar( + context.getString(R.string.clean_runtime_environment_failed) + ) + } + } + } + } + ) + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/state/MoreSettingsState.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/state/MoreSettingsState.kt new file mode 100644 index 0000000..26e9593 --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/state/MoreSettingsState.kt @@ -0,0 +1,101 @@ +package zako.zako.zako.zakoui.screen.moreSettings.state + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper +import com.sukisu.ultra.Natives +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.ThemeConfig + +/** + * 更多设置状态管理 + */ +@Stable +class MoreSettingsState( + val context: Context, + val prefs: SharedPreferences, + val systemIsDark: Boolean +) { + // 主题模式选择 + var themeMode by mutableIntStateOf( + when (ThemeConfig.forceDarkMode) { + true -> 2 // 深色 + false -> 1 // 浅色 + null -> 0 // 跟随系统 + } + ) + + // 动态颜色开关状态 + var useDynamicColor by mutableStateOf(ThemeConfig.useDynamicColor) + + // 语言设置 + var showLanguageDialog by mutableStateOf(false) + var currentAppLocale by mutableStateOf(LocaleHelper.getCurrentAppLocale(context)) + + // 对话框显示状态 + var showThemeModeDialog by mutableStateOf(false) + var showThemeColorDialog by mutableStateOf(false) + var showDpiConfirmDialog by mutableStateOf(false) + var showImageEditor by mutableStateOf(false) + + // 动态管理器配置状态 + var dynamicSignConfig by mutableStateOf(null) + var isDynamicSignEnabled by mutableStateOf(false) + var dynamicSignSize by mutableStateOf("") + var dynamicSignHash by mutableStateOf("") + var showDynamicSignDialog by mutableStateOf(false) + + + // 各种设置开关状态 + var isSimpleMode by mutableStateOf(prefs.getBoolean("is_simple_mode", false)) + var isHideVersion by mutableStateOf(prefs.getBoolean("is_hide_version", false)) + var isHideOtherInfo by mutableStateOf(prefs.getBoolean("is_hide_other_info", false)) + var isShowKpmInfo by mutableStateOf(prefs.getBoolean("show_kpm_info", false)) + var isHideZygiskImplement by mutableStateOf(prefs.getBoolean("is_hide_zygisk_Implement", false)) + var isHideSusfsStatus by mutableStateOf(prefs.getBoolean("is_hide_susfs_status", false)) + var isHideLinkCard by mutableStateOf(prefs.getBoolean("is_hide_link_card", false)) + var isHideTagRow by mutableStateOf(prefs.getBoolean("is_hide_tag_row", false)) + var isKernelSimpleMode by mutableStateOf(prefs.getBoolean("is_kernel_simple_mode", false)) + var showMoreModuleInfo by mutableStateOf(prefs.getBoolean("show_more_module_info", false)) + var useAltIcon by mutableStateOf(prefs.getBoolean("use_alt_icon", false)) + + // SELinux状态 + var selinuxEnabled by mutableStateOf(false) + + // 卡片配置状态 + var cardAlpha by mutableFloatStateOf(CardConfig.cardAlpha) + var cardDim by mutableFloatStateOf(CardConfig.cardDim) + var isCustomBackgroundEnabled by mutableStateOf(ThemeConfig.customBackgroundUri != null) + + // 图片选择状态 + var selectedImageUri by mutableStateOf(null) + + // DPI 设置 + val systemDpi = context.resources.displayMetrics.densityDpi + var currentDpi by mutableIntStateOf(prefs.getInt("app_dpi", systemDpi)) + var tempDpi by mutableIntStateOf(currentDpi) + var isDpiCustom by mutableStateOf(true) + + // 主题模式选项 + val themeOptions = listOf( + context.getString(R.string.theme_follow_system), + context.getString(R.string.theme_light), + context.getString(R.string.theme_dark) + ) + + // 预设 DPI 选项 + val dpiPresets = mapOf( + context.getString(R.string.dpi_size_small) to 240, + context.getString(R.string.dpi_size_medium) to 320, + context.getString(R.string.dpi_size_large) to 420, + context.getString(R.string.dpi_size_extra_large) to 560 + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/util/LocaleHelper.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/util/LocaleHelper.kt new file mode 100644 index 0000000..f383ec9 --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/util/LocaleHelper.kt @@ -0,0 +1,154 @@ +package zako.zako.zako.zakoui.screen.moreSettings.util + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.provider.Settings +import java.util.* + +object LocaleHelper { + + /** + * Check if should use system language settings (Android 13+) + */ + val useSystemLanguageSettings: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + /** + * Launch system app locale settings (Android 13+) + */ + fun launchSystemLanguageSettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } catch (_: Exception) { + // Fallback to app language settings if system settings not available + } + } + } + + /** + * Apply saved language setting to context (for Android < 13) + */ + fun applyLanguage(context: Context): Context { + // On Android 13+, language is handled by system + if (useSystemLanguageSettings) { + return context + } + + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val localeTag = prefs.getString("app_locale", "system") ?: "system" + + return if (localeTag == "system") { + context + } else { + val locale = parseLocaleTag(localeTag) + setLocale(context, locale) + } + } + + /** + * Set locale for context (Android < 13) + */ + @SuppressLint("ObsoleteSdkInt") + private fun setLocale(context: Context, locale: Locale): Context { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + updateResources(context, locale) + } else { + updateResourcesLegacy(context, locale) + } + } + + @SuppressLint("UseRequiresApi", "ObsoleteSdkInt") + @TargetApi(Build.VERSION_CODES.N) + private fun updateResources(context: Context, locale: Locale): Context { + val configuration = Configuration() + configuration.setLocale(locale) + configuration.setLayoutDirection(locale) + return context.createConfigurationContext(configuration) + } + + @Suppress("DEPRECATION") + @SuppressWarnings("deprecation") + private fun updateResourcesLegacy(context: Context, locale: Locale): Context { + Locale.setDefault(locale) + val resources = context.resources + val configuration = resources.configuration + configuration.locale = locale + configuration.setLayoutDirection(locale) + resources.updateConfiguration(configuration, resources.displayMetrics) + return context + } + + /** + * Parse locale tag to Locale object + */ + private fun parseLocaleTag(tag: String): Locale { + return try { + if (tag.contains("_")) { + val parts = tag.split("_") + Locale.Builder() + .setLanguage(parts[0]) + .setRegion(parts.getOrNull(1) ?: "") + .build() + } else { + Locale.Builder() + .setLanguage(tag) + .build() + } + } catch (_: Exception) { + Locale.getDefault() + } + } + + /** + * Restart activity to apply language change (Android < 13) + */ + fun restartActivity(context: Context) { + if (context is Activity && !useSystemLanguageSettings) { + context.recreate() + } + } + + /** + * Get current app locale + */ + @SuppressLint("ObsoleteSdkInt") + fun getCurrentAppLocale(context: Context): Locale? { + return if (useSystemLanguageSettings) { + // Android 13+ - get from system app locale settings + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + val localeManager = context.getSystemService(Context.LOCALE_SERVICE) as? android.app.LocaleManager + val locales = localeManager?.applicationLocales + if (locales != null && !locales.isEmpty) { + locales.get(0) + } else { + null // System default + } + } catch (_: Exception) { + null // System default + } + } else { + null // System default + } + } else { + // Android < 13 - get from SharedPreferences + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val localeTag = prefs.getString("app_locale", "system") ?: "system" + if (localeTag == "system") { + null // System default + } else { + parseLocaleTag(localeTag) + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/util/RestartActivityUtils.kt b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/util/RestartActivityUtils.kt new file mode 100644 index 0000000..8824505 --- /dev/null +++ b/manager/app/src/main/java/zako/zako/zako/zakoui/screen/moreSettings/util/RestartActivityUtils.kt @@ -0,0 +1,27 @@ +package zako.zako.zako.zakoui.screen.moreSettings.util + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import com.sukisu.ultra.ui.MainActivity + +/** + * 刷新启动器图标 + */ +fun toggleLauncherIcon(context: Context, useAlt: Boolean) { + val pm = context.packageManager + val main = ComponentName(context, MainActivity::class.java.name) + val alias = ComponentName(context, "${MainActivity::class.java.name}Alias") + + pm.setComponentEnabledSetting( + if (useAlt) alias else main, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + + pm.setComponentEnabledSetting( + if (useAlt) main else alias, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) +} \ No newline at end of file diff --git a/manager/app/src/main/jniLibs/.gitignore b/manager/app/src/main/jniLibs/.gitignore new file mode 100644 index 0000000..939b930 --- /dev/null +++ b/manager/app/src/main/jniLibs/.gitignore @@ -0,0 +1,8 @@ +libksud.so +libkernelsu.so +libsusfsd.so +libuid_scanner.so +libzakosign.so +libandroidx.graphics.path.so +libmmrl-file-manager.so +libmmrl-kernelsu.so diff --git a/manager/app/src/main/jniLibs/arm64-v8a/libksu_susfs.so b/manager/app/src/main/jniLibs/arm64-v8a/libksu_susfs.so new file mode 100644 index 0000000..dbb0b6b Binary files /dev/null and b/manager/app/src/main/jniLibs/arm64-v8a/libksu_susfs.so differ diff --git a/manager/app/src/main/jniLibs/arm64-v8a/libmagiskboot.so b/manager/app/src/main/jniLibs/arm64-v8a/libmagiskboot.so new file mode 100644 index 0000000..ce7478d Binary files /dev/null and b/manager/app/src/main/jniLibs/arm64-v8a/libmagiskboot.so differ diff --git a/manager/app/src/main/jniLibs/armeabi-v7a/libmagiskboot.so b/manager/app/src/main/jniLibs/armeabi-v7a/libmagiskboot.so new file mode 100644 index 0000000..157133c Binary files /dev/null and b/manager/app/src/main/jniLibs/armeabi-v7a/libmagiskboot.so differ diff --git a/manager/app/src/main/jniLibs/x86_64/libmagiskboot.so b/manager/app/src/main/jniLibs/x86_64/libmagiskboot.so new file mode 100644 index 0000000..095ffe5 Binary files /dev/null and b/manager/app/src/main/jniLibs/x86_64/libmagiskboot.so differ diff --git a/manager/app/src/main/res/drawable/ic_launcher_foreground.xml b/manager/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..71e9836 --- /dev/null +++ b/manager/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,773 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/drawable/ic_launcher_foreground_alt.xml b/manager/app/src/main/res/drawable/ic_launcher_foreground_alt.xml new file mode 100644 index 0000000..ba49844 --- /dev/null +++ b/manager/app/src/main/res/drawable/ic_launcher_foreground_alt.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/manager/app/src/main/res/drawable/ic_launcher_monochrome.xml b/manager/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..a620a8d --- /dev/null +++ b/manager/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/manager/app/src/main/res/drawable/ic_launcher_monochrome_alt.xml b/manager/app/src/main/res/drawable/ic_launcher_monochrome_alt.xml new file mode 100644 index 0000000..9bc37fa --- /dev/null +++ b/manager/app/src/main/res/drawable/ic_launcher_monochrome_alt.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/manager/app/src/main/res/drawable/package_import.xml b/manager/app/src/main/res/drawable/package_import.xml new file mode 100644 index 0000000..3e8d07d --- /dev/null +++ b/manager/app/src/main/res/drawable/package_import.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..67048fd --- /dev/null +++ b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_alt.xml b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_alt.xml new file mode 100644 index 0000000..9924e8d --- /dev/null +++ b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_alt.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_alt_round.xml b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_alt_round.xml new file mode 100644 index 0000000..9924e8d --- /dev/null +++ b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_alt_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/manager/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..25ca236 Binary files /dev/null and b/manager/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/manager/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/manager/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2bf22c5 Binary files /dev/null and b/manager/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/manager/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/manager/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..017b8a2 Binary files /dev/null and b/manager/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/manager/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/manager/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9d4c2e7 Binary files /dev/null and b/manager/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/manager/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/manager/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..74dd783 Binary files /dev/null and b/manager/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/manager/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/manager/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1bde7cc Binary files /dev/null and b/manager/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/manager/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/manager/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..86a772f Binary files /dev/null and b/manager/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/manager/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/manager/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9dc12ce Binary files /dev/null and b/manager/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..41d1940 Binary files /dev/null and b/manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..799cc27 Binary files /dev/null and b/manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/manager/app/src/main/res/resources.properties b/manager/app/src/main/res/resources.properties new file mode 100644 index 0000000..d5a3ddc --- /dev/null +++ b/manager/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US \ No newline at end of file diff --git a/manager/app/src/main/res/values-ar/strings.xml b/manager/app/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..542a256 --- /dev/null +++ b/manager/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,364 @@ + + + الرئيسية + غير مثبت + إضغط للتثبيت + يعمل + الإصدار: %s + غير مدعوم + KernelSU يدعم GKI kernels فقط + إصدار النواة + إصدار SuSFS + إصدار المدير + وضع SELinux + معطل + مفروض + متساهل + مجهول + مستخدم خارق + لا يمكن تشغيل %s الوحدة + فشل تعطيل الإضافة : %s + لا توجد إضافات مثبتة + الإضافات + فرز (الإجراء أولاً) + فرز (الممكن أولاً) + إلغاء التثبيت + تثبيت الوحدة + تثبيت + إعادة تشغيل + الإعدادات + إعادة تشغيل سريعة + إعادة تشغيل إلى وضع Recovery + إعادة تشغيل إلى وضع Bootloader + إعادة تشغيل إلى وضع Download + إعادة تشغيل إلى وضع EDL + من نحن + هل أنت متأكد أنك تريد إلغاء تثبيت الإضافة %s ? + تم إلغاء تثبيتها %s + فشل إلغاء تثبيت %s + الإصدار + المطور + إنعاش + إظهار تطبيقات النظام + إخفاء تطبيقات النظام + إرسال السجلات + الوضع الآمن + إعادة التشغيل لتطبيق التغييرات + الوحدات غير متاحة بسبب تعارضها مع Magisk! + تعلم KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + تعرف على كيفية تثبيت KernelSU واستخدام الإضافات + إدعمنا + KernelSU سيظل دائماً مجانياً ومفتوح المصدر. مع ذلك، يمكنك أن تظهر لنا أنك تهتم بالتبرع. + إنضم إلى قناتنا في %2$s ]]> + الإفتراضي + نموذج + مُخصّص + اسم الملف الشخصي + مجموعات + القدرات + سياق SELinux + الغاء تحميل الإضافات + فشل تحديث ملف تعريف التطبيق لـ %s + إصدار KernelSU الحالي %s منخفض جدًا بحيث لا يعمل المدير بشكل صحيح. الرجاء الترقية إلى الإصدار %s أو أعلى! + الغاء تحميل الإضافات بشكل افتراضي + القيمة الافتراضية العامة لـ\"إلغاء تحميل الإضافات\" في ملفات تعريف التطبيقات. إذا تم تمكينه، إزالة جميع تعديلات الإضافات على النظام للتطبيقات التي لا تحتوي على مجموعة ملف تعريف. + سيسمح تمكين هذا الخيار لـKernelSU باستعادة أي ملفات معدلة بواسطة الإضافات لهذا التطبيق. + المجال + القواعد + تحديث + تحميل الإضافة: %s + ابدأ التنزيل: %s + الإصدار الجديد: %s متاح ، انقر للتحديث. + تشغيل + ايقاف إجباري + إعادة تشغيل التطبيق + فشل تحديث قواعد SELinux لـ %s + سجل التغييرات + قالب ملف تعريف التطبيق + إدارة القالب المحلي وعبر الإنترنت لملف تعريف التطبيق + إنشاء قالب + تحرير القالب + المعرف + معرف القالب غير صالح + الاسم + الوصف + حفظ + حذف + عرض القالب + للقراءة فقط + معرف القالب موجود بالفعل! + استيراد / تصدير + استيراد من الحافظة + تصدير إلى الحافظة + لا يمكن العثور على القالب المحلي للتصدير! + تم الاستيراد بنجاح + مزامنة القوالب عبر الإنترنت + فشل في حفظ القالب + الحافظة فارغة! + فشل في جلب سجل التغيير: %s + التحقق من التحديث + التحقق تلقائيًا من وجود تحديثات عند فتح التطبيق + فشل في منح صلاحية الجذر! + إجراء + إغلاق + تمكين تصحيح أخطاء WebView + يمكن استخدامه لتصحيح أخطاء WebUI، يرجى تمكينه فقط عند الحاجة. + تثبيت مباشر (موصى به) + اختيار ملف + التثبيت على فتحة غير نشطة (بعد OTA) + سيتم **إجبار** جهازك على التمهيد إلى الفتحة غير النشطة الحالية بعد إعادة التشغيل! +\nاستخدم هذا الخيار فقط بعد انتهاء التحديث. +\nأستمرار؟ + التالي + يوصى باستخدام صورة القسم %1$s + اختر KMI + إلغاء التثبيت + إلغاء التثبيت مؤقتًا + إلغاء التثبيت بشكل دائم + استعادة الصورة الاصلية + قم بإلغاء تثبيت KernelSU مؤقتًا، واستعد إلى حالته الأصلية بعد إعادة التشغيل التالية. + ‬إلغاء تثبيت KernelSU .(الجذر وجميع الوحدات) بشكل كامل ودائم. + استعادة صورة المصنع المخزنة (في حالة وجود نسخة احتياطية)، والتي تُستخدم عادة قبل OTA؛ إذا كنت بحاجة إلى إلغاء تثبيت KernelSU، فيرجى استخدام \"إلغاء التثبيت الدائم\". + تركيب + نجح التركيب + فشل التركيب + LKM المحددة: %s + حفظ السجلات + السجلات محفوظة + + تأكيد وحدة التثبيت %1$s؟ + وحدة غير معروفة + + تأكيد استعادة الوحدة + هذه العملية سوف تستبدل جميع الوحدات الموجودة. هل تريد المتابعة؟ + تأكيد + إلغاء + + النسخ الاحتياطي ناجح (tar.gz) + فشل النسخ الاحتياطي: %1$s + وحدات النسخ الاحتياطي + استعادة الوحدات + + تم استعادة الوحدات بنجاح, إعادة التشغيل مطلوبة + فشل الاستعادة: %1$s + أعد تشغيل التطبيق الآن + خطأ غير معروف + + فشل تنفيذ الأوامر: %1$s + + السماح بالنسخ الاحتياطي للقائمة بنجاح + فشل النسخ الاحتياطي لقائمة السماح: %1$s + تأكيد استعادة القائمة المسموح بها + هذه العملية ستقوم بالكتابة فوق قائمة المسموح بها. هل تريد المتابعة؟ + تمت استعادة القائمة بنجاح + فشل استعادة القائمة المسموحة: %1$s + قائمة النسخ الاحتياطي + استعادة قائمة المسموح بها + خلفية التطبيق المخصصة + حدد صورة كخلفية + شفافية شريط التنقل + ‏إصدار Android + نوع الجهاز + لا يسمح بمنح المستخدم المتميز ل %s + تعطيل توافق su + تعطيل أي تطبيقات مؤقتًا من الحصول على امتيازات الجذر عن طريق الأمر <unk> su (لن تتأثر عمليات الجذر الحالية). + هل أنت متأكد من أنك تريد تثبيت وحدات %1$d التالية؟ \n\n%2$s + المزيد من الإعدادات + SELinux + مفعّل + رفض + وضع البساطة + إخفاء البطاقات غير الضرورية عند تشغيلها + إخفاء إصدار النواة + إخفاء إصدار النواة + إخفاء معلومات أخرى + يخفي معلومات عن عدد المستخدمين المتميزين والوحدات ووحدات KPM على الصفحة الرئيسية + إخفاء حالة SuSFS + إخفاء معلومات حالة SuSFS على الصفحة الرئيسية + إخفاء حالة بطاقة الرابط + إخفاء معلومات البطاقة في الصفحة الرئيسية + الثيم + اتبّاع النظام + فاتح + مظلم + ربط يدوي + لون ديناميكية + الألوان الديناميكية باستخدام سمات النظام + اختر لون السمة + أزرق + أخضر + أرجواني + برتقالي + وردي + رمادي + الأصفر + Anykernel3 yükle + فلاش AnyKernel3 ملف kernel + يتطلب امتيازات الجذر + اكتمل التشويش + هل تريد إعادة التشغيل فوراً؟ + نعم + لايوجد + فشل إعادة التشغيل + KPM + لا توجد وحدات نواة مثبتة في هذا الوقت + الإصدار + المؤلف + إلغاء التثبيت + تم إلغاء التثبيت بنجاح + فشل في إلغاء التثبيت + تم تحميل وحدة كيلو جزء بنجاح + فشل تحميل وحدة كيلو بايم + العوامل المتغيرة + تنفيذ + إصدار KPM + إغلاق + تم تطوير وظائف نواة الوحدة النمطية التالية بواسطة KernelPatch وتعديلها لتشمل وظائف نواة الوحدة النمطية لـ SukiSU Ultra + سوكيسو أولترا تتطلع إلى الأمام + نجحت + فشل + وستشكل سوكيسو أولترا في المستقبل فرعا مستقلا نسبيا من فروع الوحدة، ولكننا لا نزال نقدر كيرنيل سو وموكسو الرسميين وما إلى ذلك. لإسهاماتهم! + غير مدعوم + إدعمنا + النواة غير مصحوبة + لم يتم تكوين النواة + الإعدادات المُخصصة + KPM Install + التحميل + فسيفساء + الرجاء التحديد: %1\$s وضع تثبيت الوحدة \n\nالتحميل: قم بتحميل الوحدة \nمؤقتا: تثبيت دائم في النظام + غير قادر على التحقق من وجود ملف الوحدة + ألوان المظهر + نوع الملف غير صحيح! الرجاء تحديد ملف .kpm. + إلغاء التثبيت + سيتم إلغاء تثبيت KPM التالية: %s + استخدم إصبعين لتكبير الصورة، وأصبع واحد لسحبها لضبط الموضع + إعادة + + الفلاش اكتمل + + جار التحضير + تنظيف الملفات… + جارٍ نسخ الملف… + استخراج أداة فلاش… + تعديل البرنامج النصي الفلاش… + نواة رمادية… + الفلاش اكتمل + + حدد فتحة الفلاش + الرجاء تحديد الخانة المستهدفة للتشغيل المبطن + خانة A + الخانة B + الفتحة المحددة: %1$s + الحصول على الفتحة الأصلية + تعيين الفتحة المحددة + استعادة الخانة الافتراضية + فتحة النظام الحالية الافتراضية:%1$s + + فشل النسخ + خطأ غير معروف + فشل التركيب + + إصلاح/تثبيت LKM + نواة رمادية + إصدار النواة:%1$s + استخدام أداة التصحيح:%1$s + تعيين + إعدادات التطبيق + ادوات + + التطبيق غير موجود + تم تمكين SELinux + تم تعطيل SELinux + فشل تغيير حالة SELinux + إعدادات متقدمة + تخصيص شريط الأدوات + عد مرة أخرى + تم تعيين الخلفية بنجاح + إزالة خلفيات مخصصة + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + عرض وظيفة KPM + إخفاء معلومات KPM ووظيفتها في الشريط المنزلي والأسفل + + حدد محرك WebUI لاستخدامه + اختيار تلقائي + Force the use of WebUI X + Mandatory use of KSU WebUI + حقن Eruda في WebUI X + حقن وحدة التصحيح في WebUI X لجعل تصحيح الأخطاء أسهل. يتطلب تصحيح أخطاء الويب لتكون قيد التشغيل. + + تم تطبيق DPI + ضبط كثافة عرض الشاشة للتطبيق الحالي فقط + صغير + متوسط + كبير + حجم كبير + قابلة للتعديل + تطبيق إعدادات DPI + تأكيد تغيير إدارة شؤون الإعلام + هل أنت متأكد من أنك تريد تغيير تطبيق DPI من %1$d إلى %2$d؟ + يحتاج التطبيق إلى إعادة تشغيل لتطبيق الإعدادات الجديدة لإدارة شؤون الإعلام، ولا يؤثر على شريط حالة النظام أو التطبيقات الأخرى + تم تعيين DPI إلى %1$d، فعلي بعد إعادة تشغيل التطبيق + + لغة التطبيق + اتبع النظام + تعديل ظلام البطاقة + + رمز الخطأ + يرجى التحقق من السجل + تم تثبيت الوحدة %1$d/%2$d + أخفق %d في تثبيت وحدة جديدة + فشل تحميل الوحدة + ضرب النواة + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + في الأعلى + أسفل + محدد + خيار + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-az/strings.xml b/manager/app/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..d90028d --- /dev/null +++ b/manager/app/src/main/res/values-az/strings.xml @@ -0,0 +1,362 @@ + + + Ana səhifə + Yüklənmədi + Yükləmək üçün toxunun + İşləyir + Versiya: %s + Dəstəklənmir + Hal-hazırda KernelSU yalnız GKI nüvələrini dəstəkləyir + Nüvə + SuSFS Version + Menecer versiyası + SELinux vəziyyəti + Qeyri-aktiv + Məcburi + Sərbəst + Naməlum + Super istifadəçi + Modulu aktiv etmək mümkün olmadı: %s + Modulu deaktiv etmək mümkün olmadı: %s + Heç bir modul quraşdırılmayıb + Modul + Sort (Action first) + Sort (Enabled first) + Sil + Yüklə + Yüklə + Yenidən başlat + Parametrlər + Yüngül vəziyyətdə yenodən başlat + Bərpa rejimində yenidən başlat + Bootloader rejimində yenidən başlat + Yükləmə rejimində yenidən başlat + EDL rejimində yenidən başlat + Haqqında + Modulu silmək istədiyinizdən əminsiniz %s\? + %s silindi + Silmək mümkün olmadı: %s + Versiya + Sahib + Yenilə + Sistem proqramlarını göstər + Sistem proqramlarını gizlət + Log-u göndər + Təhlükəsiz rejimi + Qüvvəyə minməsi üçün yenidən başlat + Modular deaktiv edilir,çünki o Magisk-in modulları ilə toqquşur! + KernelSU-yu öyrən + https://kernelsu.org/guide/what-is-kernelsu.html + KernelSU-yu necə quraşdırılacağını və modulların necə istifadə ediləcəyini öyrən + Bizi dəstəkləyin + KernelSU pulsuz və açıq mənbəlidir,həmişə belə olacaqdır. Bununla belə, ianə etməklə bizə qayğı göstərdiyinizi göstərə bilərsiniz. + Join our %2$s channel]]> + Defolt + Şablon + Özəl + Profil adı + Qruplar + Bacarıqlar + SELinux konteksi + Modulları umount et + %s görə tətbiq profillərini güncəlləmək mümkün olmadı + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Defolt olaraq modulları umount et + Tətbiq Profillərində \"Umount modulları\" üçün qlobal standart dəyər. Aktivləşdirilərsə, o, Profil dəsti olmayan proqramlar üçün sistemdəki bütün modul dəyişikliklərini siləcək. + Bu seçimi aktivləşdirmək KernelSU-ya bu proqram üçün modullar tərəfindən hər hansı dəyişdirilmiş faylları bərpa etməyə imkan verəcək. + Domen + Qaydalar + Güncəllə + Modul yüklənir: %s + Endirməni başlat: %s + Yeni versiya: %s əlçatandır, endirmək üçün toxunun + + Məcburi dayandır + Yenidən başlat + %s görə SELinux qaydalarını güncəlləmək mümkün olmadı + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + Girişləri Saxla + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-bn-rBD/strings.xml b/manager/app/src/main/res/values-bn-rBD/strings.xml new file mode 100644 index 0000000..e77f90c --- /dev/null +++ b/manager/app/src/main/res/values-bn-rBD/strings.xml @@ -0,0 +1,45 @@ + + + কর্নেল এস ইউ কেবল মাত্র জিকআই কর্নেল সাপোর্ট করে + এসইলিনাক্স স্টেটাস + আননোন + মোডিউল ইনেবল করা যায়নি: %s + ইন্সটল করটে চাপুন + কাজ করছে + অমূলক + কর্নেল + ম্যানেজার ভারসন + ডিসেবল + এনফোর্সিং + সুপার ইউজার + মোডিউল + আনইন্সটল + ইন্সটল + ইন্সটল + রিবুট + সেটিংস + সফট রিবুট + গ্রুপস + এসইলিনাক্স কন্টেক্সট + %s এর জন্য অ্যাপ প্রফাইল আপডেট করা যায়নি + বাইডিফল্ট মোডিউল আনমাউন্ট + হোম + ইন্সটল হয়নী + পারমিসিভ + মোডিউল ডিসেবল করা যায়নি: %s + কোনো মোডিউল ইন্সটল করা নেই + সংস্করণ: %s + ক্যাপাবিলিটিস + আনমাউন্ট মোডিউলস + রিকভারিতে বুট + বুটলোডারে বুট + ডাউনলোড মডে বুট + ইমারজেন্সি ডাউনলোড মডে বুট + অ্যাবাউট + %s মোডিউল আনইনস্টলের বেপারে নিশ্চিৎ\? + %s আনইনস্টলড + %s আনইনস্টল করা যায়নি + ভার্সন + অথার + লগ সংরক্ষণ করুন + diff --git a/manager/app/src/main/res/values-bn/strings.xml b/manager/app/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..8d59f0a --- /dev/null +++ b/manager/app/src/main/res/values-bn/strings.xml @@ -0,0 +1,59 @@ + + + হোম + ইনস্টল করা হয়নি + ইনস্টল করার জন্য ক্লিক করুন + ওয়ার্কিং + ওয়ার্কিং সংস্করণ: %s + অসমর্থিত + KernelSU শুধুমাত্র GKI কার্নেল সমর্থন করে + কার্নেল + ম্যানেজার সংস্করণ + SELinux স্টেটাস + ডিজেবল + কার্যকর + অনুমতিমূলক + অজানা + সুপার ইউজার + মডিউল সক্ষম করতে ব্যর্থ হয়েছে: %s + মডিউল নিষ্ক্রিয় করতে ব্যর্থ হয়েছে: %s + কোন মডিউল ইনস্টল করা নেই + মডিউল + আনইন্সটল + মডিউল ইনস্টল + ইনস্টল + রিবুট + সেটিংস + সফট রিবুট + রিবুট রিকোভারি + রিবুট বুটলোডার + রিবুট ডাউনলোড + রিবুট ইডিএল + এবাউট + মডিউল আনইনস্টল নিশ্চিত করুন %s? + %s আনইনস্টল সফল + আনইন্সটল ব্যর্থ: %s + ভার্সন + লেখক + রিফ্রেশ + শো সিস্টেম অ্যাপস + হাইড সিস্টেম অ্যাপস + সেন্ড লগ + সেইফ মোড + রিবুট এপ্লাই + মডিউলগুলি অক্ষম কারণ তারা ম্যাজিস্কের সাথে বিরোধিতা করে! + লার্ন কার্নেলএসইউ + https://kernelsu.org/guide/what-is-kernelsu.html + কিভাবে কার্নেলএসইউ ইনস্টল করতে হয় এবং মডিউল ব্যবহার করতে হয় তা শিখুন + সাপোর্ট টাইটেল + কার্নেলএসইউ বিনামূল্যে এবং ওপেন সোর্স, এবং সবসময় থাকবে। আপনি সবসময় একটি অনুদান দিয়ে আপনার কৃতজ্ঞতা প্রদর্শন করতে পারেন. + প্রফাইলের নাম + গ্রুপস + যোগ্যতা + এসই লিনাক্স কনটেক্সট + ডিফল্ট + টেমপ্লেট + কাস্টম + আনমাউন্ট মোডিউল + লগ সংরক্ষণ করুন + diff --git a/manager/app/src/main/res/values-bs/strings.xml b/manager/app/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..da5e829 --- /dev/null +++ b/manager/app/src/main/res/values-bs/strings.xml @@ -0,0 +1,362 @@ + + + Početna + Nije instalirano + Kliknite da instalirate + Radi + Verzija: %s + Nepodržano + KernelSU samo podržava GKI kernele sad + Kernel + SuSFS Version + Verzija Upravitelja + SELinux stanje + Isključeno + U Provođenju + Permisivno + Nepoznato + Superkorisnik + Neuspješno uključivanje module: %s + Neuspješno isključivanje module: %s + Nema instaliranih modula + Modula + Sort (Action first) + Sort (Enabled first) + Deinstalirajte + Instalirajte + Instalirajte + Ponovo pokrenite + Podešavanja + Lagano Ponovo pokretanje + Ponovo pokrenite u Oporavu + Ponovo pokrenite u Pogonski Učitavatelj + Ponovo pokrenite u Preuzimanje + Ponovo pokrenite u EDL + O + Jeste li sigurni da želite deinstalirati modulu %s\? + %s deinstalirana + Neuspješna deinstalacija: %s + Verzija + Autor + Osvježi + Prikažite sistemske aplikacije + Sakrijte sistemske aplikacije + Pošaljite Izvještaj + Sigurnosni mod + Ponovo pokrenite da bi proradilo + Module su isključene jer je u sukobu sa Magisk-om! + Naučite KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Naučite kako da instalirate KernelSU i da koristite module + Podržite Nas + KernelSU je, i uvijek če biti, besplatan, i otvorenog izvora. Možete nam međutim pokazati da vas je briga s time da napravite donaciju. + Join our %2$s channel]]> + Zadano + Šablon + Prilagođeno + Naziv profila + Grupe + Sposobnosti + SELinux kontekst + Umount module + Ažuriranje Profila Aplikacije za %s nije uspjelo + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Umount module po zadanom + Globalna zadana vrijednost za \"Umount module\" u Profilima Aplikacije. Ako je omogućeno, uklonit će sve izmjene modula na sistemu za aplikacije koje nemaju postavljen Profil. + Uključivanjem ove opcije omogućit će KernelSU-u da vrati sve izmjenute datoteke od strane modula za ovu aplikaciju. + Domena + Pravila + Ažuriranje + Skidanje module: %s + Započnite sa skidanjem: %s + Nova verzija: %s je dostupna, kliknite da skinete + Pokrenite + Prisilno Zaustavite + Resetujte + Neuspješno ažuriranje SELinux pravila za: %s + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + Sačuvaj Dnevnike + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-da/strings.xml b/manager/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..3c3221a --- /dev/null +++ b/manager/app/src/main/res/values-da/strings.xml @@ -0,0 +1,362 @@ + + + Hjem + Ikke installeret + Klik for at installere + Arbejder + Version: %s + Ikke understøttet + KernelSU understøtter kun GKI kernels + Kernel + SuSFS Version + Manager Version + SELinux-status + Deaktiveret + Håndhævende + Tilladende + Ukendt + Superbruger + Aktivering af modul fejlede: %s + Deaktivering af modul fejlede: %s + Intet modul installeret + Modul + Sort (Action first) + Sort (Enabled first) + Afinstaller + Installer + Installer + Genstart + Indstillinger + Blød Genstart + Genstart til Recovery + Genstart til Bootloader + Genstart til Download + Genstart til EDL + Om + Er du sikker på, at du vil afinstallere modulet %s\? + %s afinstalleret + Afinstallation af: %s fejlede + Version + Forfatter + Opdater + Vis system-apps + Gem system-apps + Send Log + Sikker tilstand + Genstart for at tage effekt + Moduler er deaktiveret, fordi der er konflikt med Magiskes! + Lær KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Lær hvordan man installerer KernelSU og moduler + Støt Os + KernelSU er, og vil altid være gratis og open source. Du kan stadig vise os din støtte ved at donere. + Join our %2$s channel]]> + Standard + Skabelon + Brugerdefineret + Profilnavn + Grupper + Evner + SELinux-kontext + Afmonteret moduler + Opdatering af App Profil for %s fejlede + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Afmontere moduler som standard + Den globale standard værdi for \"Afmonter moduler\" i App Profiler. Hvis aktiveret vil den fjerne alle modulers modifikationer til system applikationerne der ikke har en sat Profil. + Aktivering af denne indstilling vil tillade KernelSU at gendanne hvilken som helst modificeret filer af modulet for denne applikation. + Domæne + Regler + Opdatering + Downloader modulet: %s + Start download: %s + Ny version: %s er tilgængelig, kilk for at downloade + Start + Tving Stop + Genstart + Opdatering af SELinux-regler for: %s fejlede + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + Gem Logfiler + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-de/strings.xml b/manager/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..b9d848a --- /dev/null +++ b/manager/app/src/main/res/values-de/strings.xml @@ -0,0 +1,364 @@ + + + Startseite + Nicht installiert + Tippe zum Installieren + Funktioniert + Version: %s + Nicht unterstützt + KernelSU unterstützt derzeit nur GKI-Kernel + Kernel + SuSFS version + Manager-Version + SELinux Status + Deaktiviert + Erzwingen + Permissiv + Unbekannt + Superuser + Modulaktivierung fehlgeschlagen: %s + Moduldeaktivierung fehlgeschlagen: %s + Keine Modul installiert + Modul + Sortiere zuerst (Aktion) + Sortieren (zuerst aktiviert) + Deinstallieren + Installieren + Installieren + Neustarten + Einstellungen + Soft-Reboot + In den Recovery-Modus neustarten + In den Bootloader-Modus neustarten + In den Download-Modus neustarten + In den EDL-Modus neustarten + Über KernelSU + Möchtest du wirklich Modul %s deinstallieren? + %s deinstalliert + Deinstallation fehlgeschlagen: %s + Version + Autor + Aktualisieren + System-Apps anzeigen + System-Apps ausblenden + Protokoll senden + Sicherer Modus + Neustarten, damit Änderungen wirksam werden + Module sind aufgrund eines Konfliktes mit Magisk nicht verfügbar! + KernelSU verstehen + https://kernelsu.org/guide/what-is-kernelsu.html + Erfahre, wie KernelSU installiert wird und wie Module verwendet werden + Unterstütze uns + KernelSU ist und wird immer frei und quelloffen sein. Du kannst uns jedoch deine Unterstützung zeigen, indem du eine Spende tätigst. + Begleiten Sie uns %2$s-Kanal]]> + Standard + Vorlage + Benutzerdefiniert + Profilname + Gruppen + Fähigkeiten + SELinux-Kontext + Module aushängen + App-Profilaktualisierung für %s fehlgeschlagen + Die aktuelle KernelSU-Version %s ist zu alt für diese Manager-Version. Bitte auf Version %s oder höher aktualisieren! + Module standardmäßig aushängen + Globaler Standardwert für \"Module aushängen\" im App-Profil. Falls er aktiviert ist, werden alle Moduländerungen im System für alle Apps entfernt, für die kein Profil festgelegt ist. + Wenn du diese Option aktivierst, kann KernelSU alle von den Modulen für diese App geänderten Dateien wiederherstellen. + Domäne + Regeln + Aktualisieren + Lädt Modul %s herunter + Starte Download: %s + Neue Version %s verfügbar, tippen zum Aktualisieren. + Starten + Stopp erzwingen + Neustarten + Aktualisieren der SELinux-Regeln schlug fehl für: %s + Änderungsprotokoll + App-Profil-Vorlage + Verwalte die lokale und online Vorlage des App-Profils + Vorlage erstellen + Vorlage bearbeiten + ID + Ungültige Vorlagen-ID + Name + Beschreibung + Speichern + Löschen + Vorlage ansehen + Schreibgeschützt + Vorlagen-ID existiert bereits! + Import/Export + Aus Zwischenablage importieren + In Zwischenablage exportieren + Kann lokale Vorlage nicht finden! + Erfolgreich importiert + Online-Vorlagen synchronisieren + Schlug beim Speichern der Vorlage fehl + Zwischenablage ist leer! + Konnte Veränderungs-Protokoll nicht laden: %s + Auf Aktualisierung prüfen + Prüfe automatisch auf Aktualisierungen, wenn die App geöffnet wird + Root-Zugriff konnte nicht gewährt werden! + Aktion + Schließen + WebView-Debugging aktivieren + Kann zum Fehlerbeheben der WebUI verwendet werden, bitte nur im Notfall aktivieren. + Direkte Installation (empfohlen) + Datei auswählen + In inaktiven Slot installieren (nach OTA) + Nach einem Neustart wird dein Gerät **GEZWUNGEN** in den derzeit inaktiven Slot zu starten! +\nBenutze dies nur nach Fertigstellung des OTA. +\nFortfahren? + Weiter + %1$s Partitionsabbild empfohlen + KMI auswählen + Deinstallieren + Temporär deinstallieren + Permanent deinstallieren + Standard-Abbild wiederherstellen + KernelSU temporär deinstallieren, originalen Status nach dem nächsten Neustart wiederherstellen. + KernelSU (Root und alle Module) vollständig und dauerhaft deinstallieren. + Das Standard Werksabbild wiederherstellen (falls ein Backup existiert), normalerweise vor einem OTA zu verwenden; falls Sie KernelSU deinstallieren müssen, nutzen Sie bitte \"Permanent deinstallieren\". + Schreibt + Schreiben erfolgreich + Schreiben fehlgeschlagen + Wähle LKM: %s + Protokolle Speichern + Protokolle gespeichert + + das Installationsmodul %1$s bestätigen ? + unbekannter Modul + + Modul-Wiederherstellung bestätigen + Diese Operation wird alle vorhandenen Module überschreiben. Fortfahren? + Bestätigen + Abbrechen + + Sicherung erfolgreich (tar.gz) + Sicherung fehlgeschlagen: %1$s + sicherungsmodule + wiederherstellen + + Module erfolgreich wiederhergestellt, Neustart erforderlich + Wiederherstellung fehlgeschlagen: %1$s + Jetzt Neustarten + Ein unbekannter Fehler ist aufgetreten + + Befehlsausführung fehlgeschlagen: %1$s + + Sicherung erfolgreich erlaubt + Sicherung der erlaubten Liste fehlgeschlagen: %1$s + Allowlist-Wiederherstellung bestätigen + Dieser Vorgang wird die aktuelle Berechtigungsliste überschreiben. Fortfahren? + Liste erfolgreich wiederhergestellt + Wiederherstellung der erlaubten Liste fehlgeschlagen: %1$s + Sicherungsliste + Allowlist wiederherstellen + Eigener App-Hintergrund + Wählen Sie ein Bild als Hintergrund + Transparenz der Navigationsleiste + Androidversion + Geräteausführung + Superuser %s zu erlauben ist nicht erlaubt + Su Kompatibilität deaktivieren + Deaktivieren Sie temporär alle Anwendungen, die root-Privilegien über den Befehl <unk> su zu erhalten (bestehende root-Prozesse werden nicht beeinflusst). + Möchten Sie die folgenden %1$d Module installieren? \n\n\n%2$s + Weitere Einstellungen + SELinux + Aktiviert + Deaktiviert + Einfachheit Modus + Versteckt unnötige Karten beim Einschalten + Kernel-Version ausblenden + Kernel-Version ausblenden + Andere Infos ausblenden + Versteckt Informationen über die Anzahl der Supernutzer, Module und KPM-Module auf der Startseite + SuSFS-Status ausblenden + SuSFS Statusinformationen auf der Startseite ausblenden + Link-Kartenstatus ausblenden + Link Karteninformationen auf der Startseite ausblenden + Thema + Systemkonform + Licht + Dunkel + Manueller Hook + Dynamische Farbe + Dynamische Farben mit System-Themes + Wähle eine Theme-Farbe + Blau + Grün + Lila + Orange + Pink + Grau + Gelb + Install Anykernel3 + Flash AnyKernel3 Kernel-Datei + Erfordert Root-Rechte + Scrubbing abgeschlossen + Ob sofort neu gestartet werden soll? + Ja + Nein + Neustart fehlgeschlagen + KPM + Keine installierten Kernelmodule + Version + Autor + Deinstallieren + Erfolgreich deinstalliert + Deinstallation fehlgeschlagen + Laden des kpm Moduls erfolgreich + Laden des kpm-Moduls fehlgeschlagen + Parameter + Ausführen + KPM-Version + Schließen + Die folgenden Kernel-Modulfunktionen wurden von KernelPatch entwickelt und so modifiziert, dass die Funktionen des Kernel-Moduls von SukiSU Ultra enthalten sind + SukiSU Ultra freut sich auf + Erfolgreich + Fehlgeschlagen + SukiSU Ultra wird in Zukunft ein relativ unabhängiger Zweig der KSU sein, aber wir schätzen immer noch die offiziellen KernelSU und MKSU usw. für ihre Beiträge! + Nicht unterstützt + Unterstützt: + Kernel nicht gepatcht + Kernel nicht konfiguriert + Eigene Einstellungen + KPM Install + Laden + Einbetten + Bitte wählen: %1\$s Modul-Installationsmodus \n\nLaden: Das Modul \ntemporär laden: Dauerhaft in das System installieren + Kann nicht überprüfen, ob die Moduldatei existiert + Themenfarbe + Falscher Dateityp! Bitte wählen Sie eine .kpm Datei. + Deinstallieren + Folgende KPM wird deinstalliert: %s + Verwende zwei Finger um das Bild zu vergrößern und einen Finger um die Position anzupassen + Rückzahlung + + Blitz abgeschlossen + + Vorbereiten… + Bereinigung von Dateien… + Kopiere Datei… + Entpacken des Flash-Tools… + Patcht Flash-Skript… + Flashen des Kernels… + Blitz abgeschlossen + + Wähle Flash Slot + Bitte wählen Sie den Ziel-Slot zum Blinken des Boots aus + Slot A + Steckplatz B + Wähle LKM: %1$s + Den ursprünglichen Slot erhalten + Setze den angegebenen Slot + Standard wiederherstellen + Aktueller Standard-Slot des Systems:%1$s + + Kopieren fehlgeschlagen + Ein unbekannter Fehler ist aufgetreten + Schreiben fehlgeschlagen + + LKM Reparatur/Installation + Flashen des Kernels… + Kernel + Benutze das Patchwerkzeug:%1$s + Konfigurieren + Anwendungs-Einstellungen + Tools + + Anwendung nicht gefunden + SELinux aktiviert + SELinux deaktiviert + SELinux Statusänderung fehlgeschlagen + Erweiterte Einstellungen + Passt die Symbolleiste an. + Comeback + Hintergrund erfolgreich gesetzt + Eigene Hintergründe entfernt + Alternatives Symbol + Ändere das Launcher-Symbol auf das KernelSU Icon. + Icon gewechselt + + KPM-Funktion anzeigen + Versteckt KPM-Informationen und Funktion in der Home- und Unterleiste + + Wähle die zu verwendende WebUI-Engine + Automatisch auswählen + Nutzung von WebUI X erzwingen + Pflichtanwendung von KSU WebUI + Eruda in WebUI X injizieren + Fügen Sie eine Debug-Konsole in WebUI X ein, um das Debuggen zu vereinfachen. Benötigt Debugging im WebUI X. + + Angewendeter DPI + Bildschirmanzahl nur für die aktuelle Anwendung anpassen + Klein + Mittel + Groß + übergröße + anpassbar + DPI-Einstellungen anwenden + DPI-Änderung bestätigen + Sind Sie sicher, dass Sie die Anwendung DPI von %1$d auf %2$d ändern möchten? + Die Anwendung muss neu gestartet werden, um die neuen DPI-Einstellungen zu übernehmen, hat keine Auswirkungen auf die System-Statusleiste oder andere Anwendungen + DPI wurde auf %1$dgesetzt, wirksam nach dem Neustart der Anwendung + + App Sprache + Folge Systemeinstellung + Kartenfinsternis Anpassung + + fehlercode + Bitte überprüfen Sie das Log + Modul wird installiert %1$d/%2$d + %d Fehler bei der Installation eines neuen Moduls + Modul-Download fehlgeschlagen + Kernel-Flashen + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Unten + Ausgewählt + variieren + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-es/strings.xml b/manager/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..451ee77 --- /dev/null +++ b/manager/app/src/main/res/values-es/strings.xml @@ -0,0 +1,362 @@ + + + Inicio + No instalado + Haz clic para instalar + Funcionando + Versión: %s + Sin soporte + KernelSU solo admite kernels GKI por ahora + Versión del kernel + Versión SuSFS + Versión del gestor + Estado de SELinux + Desactivado + Estricto + Permisivo + Desconocido + Superusuario + Error al activar el módulo: %s + Error al desactivar el módulo: %s + Ningún módulo instalado + Módulo + Ordenar (Acción primero) + Ordenar (Activado primero) + Desinstalar + Instalar + Instalar + Reiniciar + Ajustes + Reinicio suave + Reiniciar en modo de recuperación + Reiniciar en modo de arranque + Reiniciar en modo Download + Reiniciar en modo EDL + Acerca de + ¿Está seguro de que desea desinstalar el módulo %s? + %s desinstalado + Fallo al desinstalar: %s + Versión + Autor + Refrescar + Mostrar aplicaciones del sistema + Ocultar aplicaciones del sistema + Enviar registros + Modo seguro + Reinicia para aplicar cambios + ¡Los módulos no están disponibles debido a un conflicto con Magisk! + Aprende KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Aprende a instalar KernelSU y a utilizar módulos + Apóyanos + KernelSU es, y siempre será, gratuito y de código abierto. Sin embargo, puedes demostrarnos que te importamos haciendo una donación. + Únete a nuestro canal %2$s]]> + Predeterminado + Plantilla + Personalizado + Nombre de perfil + Grupos + Capacidades + Contexto SELinux + Desmontar módulos + Error al actualizar el perfil de la aplicación para %s + La versión %s actual de KernelSU es demasiado baja para que el gestor funcione correctamente. Por favor, ¡actualice a la versión %s o superior! + Desmontar módulos por defecto + El valor global predeterminado para \"Umount modules\" en App Profile. Si está activado, eliminará todas las modificaciones de módulos del sistema para las apps que no tengan un perfil establecido. + Activar esta opción permitirá a KernelSU restaurar cualquier archivo modificado por los módulos para esta aplicación. + Dominio + Reglas + Actualizar + Descargando módulo: %s + Iniciar descarga: %s + La nueva versión %s está disponible, haga clic para actualizar. + Iniciar + Forzar detención + Reiniciar + Error al actualizar las reglas SELinux para: %s + Registro de cambios + Plantilla de perfil de aplicación + Gestionar la plantilla local y en línea de App Profile + Crear plantilla + Editar plantilla + ID + ID de plantilla no válida + Nombre + Descripción + Guardar + Eliminar + Ver plantilla + Sólo lectura + ¡El ID de plantilla ya existe! + Importar/Exportar + Importar desde el portapapeles + Exportar al portapapeles + ¡No se encuentra la plantilla local para exportar! + Importado con éxito + Sincronizar plantillas en línea + No se ha podido guardar la plantilla + ¡El portapapeles está vacío! + Fallo en la obtención del registro de cambios: %s + Comprobar actualización + Comprobación automática de actualizaciones al abrir la aplicación + ¡No se ha podido conceder el acceso root! + Aktion + Cancelar + Activar la depuración de WebView + Puede ser usado para depurar WebUI, por favor habilítalo sólo cuando sea necesario. + Instalación directa (Recomendada) + Seleccione un archivo + Instalar en ranura inactiva (Después de OTA) + ¡Su dispositivo será **FORZADO** a arrancar en la ranura inactiva actual después de un reinicio!\nUtilice esta opción sólo después de que la OTA se haya realizado.\n¿Continuar? + Siguiente + Se recomienda la imagen de partición %1$s + Selecciona KMI + Desinstalar + Desinstalar temporalmente + Desinstalar permanentemente + Restaurar imagen de archivo + Desinstalar temporalmente KernelSU, restaurar al estado original tras el siguiente reinicio. + Desinstalar KernelSU (Root y todos los módulos) completa y permanentemente. + Restaurar la imagen de fábrica stock (Si existe una copia de seguridad), por lo general se utiliza antes de OTA; si necesita desinstalar KernelSU, por favor, utilice \"Desinstalar permanentemente\". + Intermitencia + Éxito de Flash + Flash falló + LKM seleccionado: %s + Guardar registros + Registro guardado + + ¿confirmar la instalación del módulo %1$s? + módulo desconocido + + Confirmar restauración del módulo + Esta operación sobrescribirá todos los módulos existentes. ¿Continuar? + Confirmar + Cancelar + + Copia de seguridad exitosa (tar.gz) + Copia de seguridad fallida: %1$s + módulos de respaldo + restaurar módulos + + Módulos restaurados con éxito, se requiere reiniciar + Restauración fallida: %1$s + Reiniciar ahora + Error desconocido + + Ejecución del comando fallida: %1$s + + Copia de seguridad correcta + Copia de seguridad de lista fallida: %1$s + Confirmar restauración de lista de permisos + Esta operación sobrescribirá la lista permitida actual. ¿Continuar? + Lista restaurada correctamente + Restauración de lista de permisos falló: %1$s + Copia de seguridad lista + Restaurar lista de permisos + Fondo de aplicación personalizado + Seleccionar una imagen como fondo + Transparencia de la barra de navegación + Versión de Android + Modelo del dispositivo + No se permite conceder superusuario a %s + Desactivar compatibilidad su + Deshabilita temporalmente cualquier aplicación para obtener privilegios de root a través del comando de \"it\" (los procesos de root existentes no se verán afectados). + ¿Seguro que quieres instalar los siguientes módulos %1$d ? \n\n%2$s + Opciones avanzadas + SELinux + Habilitado + Desactivado + Modo de simplicidad + Ocultar tarjetas innecesarias al encender + Ocultar versión del núcleo + Ocultar versión del núcleo + Ocultar otra información + Oculta información sobre el número de superusuarios, módulos y módulos KPM en la página de inicio + Ocultar estado SuSFS + Ocultar información de estado de SuSFS en la página de inicio + Ocultar el estado de la tarjeta de enlace + Ocultar información de la tarjeta de enlace en la página de inicio + Temas + Predeterminado del sistema + Claro + Oscuro + Gancho manual + Color dinámico + Colores dinámicos usando temas del sistema + Elegir un color de tema + Azul + Verde + Morado + Naranjo + Rosa + Gris + Amarillo + Install Anykernel3 + Flash archivo del kernel AnyKernel3 + Requiere privilegios de root + Desguace completo + ¿Reiniciar inmediatamente? + Si + No + Reinicio fallido + KPM + No hay módulos del núcleo instalados en este momento + Versión + Autor + Desinstalar + Desinstalado con éxito + Error al desinstalar + Carga exitosa del módulo kpm + Error al cargar el módulo kpm + Parámetros + Empezar + Versión de KPM + Cancelar + Las siguientes funciones del módulo del núcleo fueron desarrolladas por KernelPatch y modificadas para incluir las funciones del módulo del núcleo de SukiSU Ultra + SukiSU Ultra espera a + Correctamente realizado + Fallido + SukiSU Ultra será una rama relativamente independiente de KSU en el futuro, pero todavía apreciamos el KernelSU oficial y MKSU etc. ¡por sus contribuciones! + Sin soporte + Apoyado + Kernel no parcheado + Kernel no configurado + Ajustes personalizados + KPM Install + Cargar + Insertar + Por favor seleccione: %1\$s Modo de instalación del Módulo \n\nCarga: Cargar temporalmente el módulo \nInsertar: Instalar permanentemente en el sistema + No se puede comprobar si el archivo de módulo existe + Color del tema + ¡Tipo de archivo incorrecto! Por favor seleccione el archivo .kpm. + Desinstalar + El siguiente KPM será desinstalado: %s + Usa dos dedos para acercar la imagen, y un dedo para arrastrarla para ajustar la posición + Reaprovisionamiento + + Flashear completo + + Preparando… + Limpiando archivos… + Copiando archivos… + Extrayendo herramienta flash… + Parcheando script flash… + Flashear kernel… + Flash completado + + Seleccionar Ranura Flash + Por favor, seleccione la ranura de destino para flashear el arranque + Slot A + Slot B + Slot selectionada + Obteniendo la ranura original + Establecer la ranura especificada + Restaurar Ranura Predeterminada + Ranura predeterminada del sistema actual:%1$s + + Hubo un fallo al copiar + Error desconocido + Flash falló + + Reparación/instalación de LKM + Flashear kernel + Versión del kernel + Usando la herramienta de parches:%1$s + Configurar + Configuración de la Aplicación + Herramientas + + No se ha encontrado la solicitud + SELinux habilitado + SELinux desactivado + Error al cambiar el estado de SELinux + Configuraciones avanzadas + Personalizar la barra de herramientas. + Retorno + Fondo establecido correctamente + Eliminar fondo personalizado + Icono alternativo + Cambiar el icono del lanzador al icono de KernelSU. + Icono cambiado + + Mostrar función KPM + Oculta la información y función del KPM en la barra de inicio e inferior + + Seleccione el motor WebUI a usar + Selección automática + Forzar el uso de WebUI X + Uso obligatorio de KSU WebUI + Inyectar Eruda en WebUI X + Inyecta una consola de depuración en WebUI X para facilitar la depuración. Requiere que la depuración web esté encendida. + + DPI aplicado + Ajustar la densidad de pantalla para la aplicación actual + Pequeño + Medio + Original + sobretamaño + personalizable + Aplicando ajustes de DPI + Confirmar cambio DPI + ¿Estás seguro de que quieres cambiar el DPI de la aplicación de %1$d a %2$d? + La aplicación necesita reiniciarse para aplicar la nueva configuración DPI, no afecta a la barra de estado del sistema u otras aplicaciones + DPI ha sido establecido a %1$d, efectivo después de reiniciar la aplicación + + Idioma de la aplicación + Seguir sistema + Ajuste de oscuridad de tarjeta + + código de error + Por favor, compruebe el registro + Módulo instalado %1$d/%2$d + %d falló al instalar un nuevo módulo + La descarga del modelo falló + Parpadeo Kernel + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Arriba + Abajo + Seleccionados + opción + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-et/strings.xml b/manager/app/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..7c8640a --- /dev/null +++ b/manager/app/src/main/res/values-et/strings.xml @@ -0,0 +1,362 @@ + + + Kodu + Pole paigaldatud + Klõpsa paigaldamiseks + Töötamine + Versioon: %s + Mittetoetatud + KernelSU toetab hetkel vaid GSI tuumasid + Tuum + SuSFS Version + Manageri versioon + SELinuxi olek + Keelatud + Jõustav + Lubav + Teadmata + Superkasutaja + Mooduli lubamine ebaõnnestus: %s + Mooduli keelamine ebaõnnestus: %s + Mooduleid pole paigaldatud + Moodul + Sort (Action first) + Sort (Enabled first) + Eemalda + Paigalda + Paigalda + Taaskäivita + Seaded + Pehme taaskäivitus + Taaskäivita taastusesse + Taaskäivita käivituslaadurisse + Taaskäivita allalaadimisrežiimi + Taaskäivita EDL-i + Teave + Kas soovid kindlasti eemaldada mooduli %s? + %s eemaldatud + Eemaldamine ebaõnnestus: %s + Versioon + Autor + Värskenda + Kuva süsteemirakendused + Peida süsteemirakendused + Saada logid + Turvarežiim + Muudatuste rakendamiseks taaskäivita + Moodulid pole saadaval Magiski konflikti tõttu! + Õpi KernelSUd + https://kernelsu.org/guide/what-is-kernelsu.html + Õpi KernelSUd paigaldama ja mooduleid kasutama + Toeta meid + KernelSU on, ja alati jääb, tasuta ning avatud lähtekoodiga kättesaadavaks. Sellegipoolest võid sa näidata, et hoolid, ning teha annetuse. + Join our %2$s channel]]> + Vaikimisi + Mall + Kohandatud + Profiili nimi + Grupid + Võimekused + SELinux kontekst + Lahtihaagitud moodulid + Rakenduseprofiili uuendamine %s jaoks ebaõnnestus + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Haagi moodulid vaikimisi lahti + Globaalne vaikeväärtus \"Lahtihaagitud moodulitele\" rakenduseprofiilis. Lubamisel eemaldab see kõik moodulite süsteemimuudatused rakendustele, millel ei ole profiili määratud. + Selle valiku lubamine lubab KernelSU-l taastada selle rakenduse moodulite poolt mistahes muudetud faile. + Domeen + Reeglid + Uuenda + Mooduli allalaadimine: %s + Allalaadimise alustamine: %s + Uus versioon %s on saadaval, klõpsa täiendamiseks. + Käivita + Sundpeata + Taaskäivita + SELinux reeglite uuendamine ebaõnnestus: %s + Muudatuste logi + Rakenduseprofiili mall + Halda kohalikke ja võrgusolevaid rakenduseprofiili malle + Loo mall + Muuda malli + ID + Sobimatu malli ID + Nimi + Kirjeldus + Salvesta + Kustuta + Vaata malli + Vaid lugemiseks + Malli ID juba eksisteerib! + Impordi/ekspordi + Impordi lõikelaualt + Ekspordi lõikelauale + Ei saa eksportida, kohalikku malli ei leitud! + Edukalt imporditud + Sünkrooni võrgumallid + Malli salvestamine ebaõnnestus + Lõikelaud on tühi! + Muudatuste logi hankimine ebaõnnestus: %s + Kontrolli uuendusi + Rakenduse avamisel kontrolli automaatselt uuendusi + Juurkasutaja andmine ebaõnnestus! + Action + Close + Luba WebView silumine + Saab kasutada WebUI silumiseks, palun luba ainult vajadusel. + Otsene paigaldus (soovitatud) + Vali fail + Paigalda ebaaktiivsesse lahtrisse (pärast üle-õhu uuendust) + Sinu seade **SUNNITAKSE** pärast taaskäivitust ebaaktiivsesse lahtrisse käivituma!\nKasuta seda valikut vaid siis, kui tegid üle-õhu uuenduse.\nJätkad? + Edasi + %1$s partitsioonitõmmis on soovitatud + Vali KMI + Eemalda + Eemalda ajutiselt + Eemalda püsivalt + Taasta vaikimisi tõmmis + Eemalda KernelSU ajutiselt, taasta pärast taaskäivitust algseisu. + KernelSU eemaldamine (juurkasutaja ja kõik moodulid) täielikult ja püsivalt. + Taasta tehase-vaiketõmmis (kui varundus eksisteerib), tavaliselt kasutatakse enne üle-õhu uuendust; kui soovid KernelSU-d eemaldada, palun kasuta \"Eemalda püsivalt\". + Välgutamine + Välgutamine õnnestus + Välgutamine ebaõnnestus + Valitud LKM: %s + Salvesta Logid + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-fa/strings.xml b/manager/app/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..e142416 --- /dev/null +++ b/manager/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,362 @@ + + + خانه + نصب نشده است + برای نصب ضربه بزنید + به درستی کار می‌کند + نسخه: %s + پشتیبانی نشده + کرنل اس یو فقط هسته های gki را پشتیبانی میکند + هسته + SuSFS Version + نسخه برنامه + وضعیت SELinux + غیرفعال + قانونمند + آزاد + ناشناخته + دسترسی روت + فعال کردن ماژول ناموفق بود: %s + غیرفعال کردن ماژول ناموفق بود: %s + هیچ ماژولی نصب نشده است + ماژول + Sort (Action first) + Sort (Enabled first) + لغو نصب + نصب + نصب + راه اندازی دوباره + تنظیمات + راه اندازی نرم + راه اندازی به ریکاوری + راه اندازی به بوتلودر + راه اندازی به حالت دانلود + راه اندازی به EDL + درباره + آیا مطمئنید که میخواهید ماژول %s را پاک کنید؟ + %s پاک شد + پاک کردن ناموفق بود: %s + نسخه + سازنده + تازه‌سازی + نمایش برنامه های سیستمی + مخفی کردن برنامه های سیستمی + ارسال وقایع + حالت امن + راه‌اندازی مجدد برای تاثیرگذاری + مازول به دلیل تعارض با مجیسک غیرفعال شده اند\'s! + یادگیری کرنل اس یو + https://kernelsu.org/guide/what-is-kernelsu.html + یاد بگیرید چگونه از کرنل اس یو و ماژول ها استفاده کنید + از ما حمایت کنید + KernelSU رایگان است و همیشه خواهد بود و منبع باز است. با این حال، می توانید با اهدای کمک مالی به ما نشان دهید که برایتان مهم است. + Join our %2$s channel]]> + پیش‌فرض + قالب + شخصی سازی شده + اسم پروفایل + Groups + Capabilities + SELinux context + جداکردن ماژول ها + Failed to update App Profile for %s + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Umount modules by default + The global default value for \"Umount modules\" in App Profile. If enabled, it will remove all module modifications to the system for apps that don\'t have a profile set. + Enabling this option will allow KernelSU to restore any modified files by the modules for this app. + Domain + Rules + Update + Downloading module: %s + Start downloading: %s + New version %s is available, click to upgrade. + Launch + Force stop + Restart + Failed to update SELinux rules for %s + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + ذخیره گزارش‌ها + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-fil/strings.xml b/manager/app/src/main/res/values-fil/strings.xml new file mode 100644 index 0000000..afcc0b2 --- /dev/null +++ b/manager/app/src/main/res/values-fil/strings.xml @@ -0,0 +1,362 @@ + + + Home + Hindi naka-install + Pindutin para mag-install + Gumagana + Bersyon: %s + Hindi Suportado + Sinusuportahan lang ng KernelSU ang mga kernel ng GKI ngayon + Kernel version + SuSFS Version + Bersyon ng Manager + Katayuan ng SELinux + Hindi pinagana + Enforcing + Permissive + Hindi matukoy + Superuser + Nabigong paganahin ang modyul: %s + Nabigong i-disable ang modyul: %s + Walang naka-install na modyul + Modyul + Sort (Action first) + Sort (Enabled first) + I-uninstall + I-install + I-install + I-reboot + Mga setting + I-soft Reboot + I-reboot sa Recovery + I-reboot sa Bootloader + I-reboot sa Download + I-reboot sa EDL + Tungkol + Sigurado ka bang gusto mong i-uninstall ang modyul %s\? + Na-uninstall ang %s + Nabigong i-uninstall: %s + Bersyon + May-akda + I-refresh + Ipakita ang mga application ng system + Itago ang mga application ng system + Magpadala ng Log + Safe mode + I-reboot para umepekto + Hindi pinagana ang mga modyul dahil salungat ito sa Magisk! + Alamin ang KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Matutunan kung paano mag-install ng KernelSU at gumamit ng mga modyul + Suportahan Kami + Ang KernelSU ay, at palaging magiging, libre, at open source. Gayunpaman, maaari mong ipakita sa amin na nagmamalasakit ka sa pamamagitan ng pagbibigay ng donasyon. + Join our %2$s channel]]> + Default + Template + Custom + Pangalan ng profile + Mga Grupo + Mga Kakayanan + Konteksto ng SELinux + I-unmount ang mga modyul + Nabigong i-update ang App Profile para sa %s + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Umount modules by default + Ang pangkalahatang default na halaga para sa \"Umount modules\" sa Mga Profile ng App. Kung pinagana, aalisin nito ang lahat ng mga pagbabago sa modyul sa system para sa mga aplikasyon na walang hanay ng Profile. + Ang pagpapagana sa opsyong ito ay magbibigay-daan sa KernelSU na ibalik ang anumang binagong file ng mga modyul para sa aplikasyon na ito. + Domain + Mga Tuntunin + Update + Nagda-download ng modyul: %s + Simulan ang pag-download: %s + Bagong bersyon: Available ang %s, i-click upang i-download + Ilunsad + Pilit na I-hinto + I-restart + Nabigong i-update ang mga panuntunan ng SELinux para sa: %s + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + I-save ang mga Log + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-fr/strings.xml b/manager/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..6d46515 --- /dev/null +++ b/manager/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,364 @@ + + + Accueil + Non installé + Appuyez ici pour installer + Fonctionnel + Version : %s + Non pris en charge + KernelSU ne prend désormais en charge que les noyaux GKI + Noyau + Version SuSFS + Version du gestionnaire + Mode SELinux + Désactivé + Enforcing + Permissive + Inconnu + Super-utilisateur + Échec de l\'activation du module : %s + Échec de la désactivation du module : %s + Aucun module installé + Modules + Trier par action + Trier par activé + Désinstaller + Installer + Installer + Redémarrer + Paramètres + Redémarrage progressif + Redémarrer en mode de récupération + Redémarrer en mode bootloader + Redémarrer en mode de téléchargement + Redémarrer en mode EDL + À propos + Êtes-vous sûr(e) de vouloir désinstaller le module %s \? + %s a été désinstallé + Échec de la désinstallation : %s + Version + Auteur + Rafraîchir + Afficher les applications système + Masquer les applications système + Envoyer les journaux + Mode sans échec + Redémarrez pour appliquer les modifications + Les modules sont indisponibles en raison d\'un conflit avec Magisk ! + Découvrir KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Découvrez comment installer KernelSU et utiliser les modules + Soutenez-nous + KernelSU est, et restera toujours, gratuit et open source. Vous pouvez cependant nous témoigner de votre soutien en nous faisant un don. + Rejoignez notre canal %2$s]]> + Par défaut + Modèle + Personnalisé + Nom du profil + Groupes + Capacités + Contexte SELinux + Démonter les modules + Échec de la modification du profil d\'application de %s + La version actuelle de KernelSU (%s) est trop ancienne pour que le gestionnaire fonctionne correctement. Veuillez passer à la version %s ou à une version supérieure ! + Démonter les modules par défaut + Valeur globale par défaut pour l\'option \"Démonter les modules\" dans les profils d\'application. Lorsque l\'option est activée, les modifications apportées au système par les modules sont supprimées pour les applications qui n\'ont pas de profil défini. + L\'activation de cette option permettra à KernelSU de restaurer tous les fichiers modifiés par les modules pour cette application. + Domaine + Règles + Mettre à jour + Téléchargement du module : %s + Début du téléchargement de : %s + La nouvelle version %s est disponible, appuyez ici pour mettre à jour. + Lancer + Forcer l\'arrêt + Relancer l\'application + Échec de la mise à jour des règles SELinux pour : %s + Journal des modifications + Modèles de profils d\'application + Gérer les modèles de profils d\'application locaux et en ligne + Créer un modèle + Modifier le modèle + ID + ID de modèle invalide + Nom + Description + Enregistrer + Supprimer + Voir le modèle + Lecture seule + L\'ID du modèle existe déjà ! + Importer/exporter + Importer à partir du presse-papiers + Exporter vers le presse-papiers + Impossible de trouver un modèle local à exporter ! + Importation réussie + Synchroniser les modèles en ligne + Échec de l\'enregistrement du modèle + Le presse-papiers est vide ! + Échec de récupération du journal des modifications : %s + Vérifier les mises à jour + Vérifier automatiquement les mises à jour à l\'ouverture de l\'application + Échec de l\'octroi des privilèges root ! + Action + Fermer + Activer le débogage WebView + Peut être utilisé pour déboguer WebUI. Activez uniquement cette option si nécessaire. + Installation directe (recommandé) + Sélectionner un fichier + Installer dans l\'emplacement inactif (après OTA) + Votre appareil sera **FORCÉ** à démarrer sur l\'emplacement inactif actuel après un redémarrage ! +\nN\'utilisez cette option qu\'une fois la mise à jour OTA terminée. +\nContinuer ? + Suivant + L\'image de la partition %1$s est recommandée + Sélectionner une KMI + Désinstaller + Désinstaller temporairement + Désinstaller définitivement + Restaurer l\'image d\'origine + Désinstaller KernelSU temporairement et rétablir l\'état original au redémarrage suivant. + Désinstallation complète et permanente de KernelSU (root et tous les modules). + Restaurer l\'image d\'origine d\'usine (s\'il en existe une sauvegarde). Utilisé généralement avant une mise à jour OTA ; si vous devez désinstaller KernelSU, utilisez plutôt l\'option \"Désinstaller définitivement\". + Flash en cours + Flash réussi + Échec du flash + LKM sélectionné : %s + Enregistrer les journaux + Journaux enregistrés + + confirmer l\'installation du module %1$s? + module inconnu + + Confirmer la restauration + Cette opération va écraser les modules existants. Continuer ? + Confirmer + Annuler + + Sauvegarde réussie (tar.gz) + Échec de la sauvegarde : %1$s + modules de sauvegarde + Restaurer les modules + + Succès de la sauvegarde, redémarrer + Échec de la restauration : %1$s + Redémarrer + Erreur inconnue + + L\'exécution de la commande a échoué : %1$s + + Sauvegarde de la liste blanche réussie + La sauvegarde de la liste d\'autorisations a échoué : %1$s + Confirmer la restauration de la liste blanche + Cette opération écrasera la liste blanche actuelle. Continuer ? + Liste blanche restaurée avec succès + La restauration de la liste d\'autorisations a échoué : %1$s + Sauvegarder la liste blanche + Restaurer la liste blanche + Arrière-plan personnalisé de l\'application + Image as arrière-plan + Transparence de la barre de navigation + Version Android + Modèle du téléphone + Donner un super-utilisateur à %s n\'est pas autorisé + Désactiver la compatibilité su + Désactiver temporairement l\'accès des applications aux privilèges root via la commande su (les processus root existants ne seront pas affectés). + Êtes-vous sûr de vouloir installer les modules %1$d suivants ? \n\n%2$s + Autres configurations + SELinux + Activé + Désactivé + Me simple + Masque les cartes inutiles lorsqu\'il est activé + Masquer la version du noyau + Masquer la version du noyau + Masquer les autres infos + Masque des informations sur le nombre de super utilisateurs, de modules et de modules KPM sur la page d\'accueil + Masquer le statut SuSFS + Masquer les informations de la carte de lien sur la page d\'accueil + Masquer le statut du lien de la carte + Masquer les informations de la carte de lien sur la page d\'accueil + Thème + Suivre le système + Clair + Sombre + Crochet manuel + Couleur dynamique + Couleurs dynamiques en utilisant des thèmes système + Choisir une couleur de thème + Bleu + Vert + Violet + Orange + Rose + Gris + Jaune + Install Anykernel3 + Fichier noyau AnyKernel3 + Nécessite les privilèges root + Traitement terminé + Redémarrer immédiatement ? + Oui + Non + Échec du redémarrage + KPM + Aucun module de noyau installé pour le moment + Version + Auteur + Désinstaller + Désinstallé avec succès + Échec de la désinstallation : + Chargement du module kpm réussi + Le chargement du module kpm a échoué + Paramètres + Exécuter + Version de KPM + Fermer + Les fonctions suivantes du module du noyau ont été développées par KernelPatch et modifiées pour inclure les fonctions du module du noyau de SukiSU Ultra + SukiSU Ultra attend avec impatience + Succès + Echoué + SukiSU Ultra sera une branche relativement indépendante de KSU dans le futur, mais nous apprécions toujours le KernelSU officiel, MKSU etc. pour leurs contributions! + Non pris en charge + Pris en charge + Noyau non corrigé + Noyau non configuré + Paramètres personnalisés + KPM Installé + Charger + Intégrer + Veuillez sélectionner : %1\$s Mode d\'installation du module \n\nCharge : Chargez temporairement le module \nIntégré: Installez définitivement dans le système + Impossible de vérifier si le fichier du module existe + Couleur du thème + Type de fichier incorrect ! Veuillez sélectionner un fichier .kpm. + Désinstaller + Le KPM suivant sera désinstallé : %s + Utilisez deux doigts pour zoomer l\'image, et un doigt pour le faire glisser pour ajuster la position + Remise à disposition + + Flash terminé + + Préparation de… + Nettoyage des fichiers… + Copie des fichiers… + Extraction de l\'outil flash… + Mise à jour du script… + Flash du noyau… + Flash complété + + Sélectionnez l\'emplacement de Flash + Veuillez sélectionner l\'emplacement cible pour le démarrage du flash + Slot A + Slot B + LKM sélectionné : %1$s + Obtention de l\'emplacement original + Définition de l\'emplacement spécifié + Restaurer l\'emplacement par défaut + Emplacement actuel + + Copie échouée + Erreur inconnue + Échec du flash + + Réparation/installation LKM + Flash du noyau… + Version du noyau:%1$s + Utilisation de l\'outil de correctifs:%1$s + Configurer + Paramètres de l\'application + Outils + + Application introuvable + SELinux activé + SELinux désactivé + La modification du statut SELinux a échoué + Paramètres avancés + Choisir les boutons à afficher + Reviens + Fond d\'écran défini avec succès + Fond d\'écran personnalisé supprimé + Icône alternative + Changer l\'icône du lanceur en icône de KernelSU. + Icône changée + + Afficher la fonction KPM + Masque les informations et fonctions KPM dans la barre d\'accueil et en bas + + Sélectionnez le moteur WebUI à utiliser + Sélectionner automatiquement + Forcer l\'utilisation de WebUI X + Utilisation obligatoire de KSU WebUI + Injecter Eruda dans WebUI X + Injectez une console de débogage dans WebUI X pour faciliter le débogage. Nécessite que le débogage soit activé. + + DPI appliqué + Ajuster la densité d\'affichage de l\'écran pour l\'application actuelle uniquement + Petit + Moyenne + Grand + surtaille + Personnalisable + Application des paramètres DPI + Confirmer le changement de DPI + Êtes-vous sûr de vouloir changer le DPI de l\'application de %1$d à %2$d? + L\'application doit être redémarrée pour appliquer les nouveaux paramètres de DPI, n\'affecte pas la barre d\'état du système ou d\'autres applications + Le DPI a été réglé sur %1$d, effectif après le redémarrage de l\'application + + Langue de l\'application + Suivre le paramètre système + Ajustement de l\'obscurité de la carte + + code d\'erreur + Veuillez vérifier le journal + Module en cours d\'installation %1$d/%2$d + %d a échoué à installer un nouveau module + Le téléchargement du modèle a échoué + Clignotement du noyau + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + En haut + En Bas + Sélectionné + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-gl/strings.xml b/manager/app/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..89956f2 --- /dev/null +++ b/manager/app/src/main/res/values-gl/strings.xml @@ -0,0 +1,4 @@ + + + Inicio + \ No newline at end of file diff --git a/manager/app/src/main/res/values-hi/strings.xml b/manager/app/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..cde029c --- /dev/null +++ b/manager/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,362 @@ + + + होम + इंस्टाल नहीं हुआ + इंस्टाल करने के लिए क्लिक करें + काम कर रहा है + वर्जन: %s + सपोर्ट नहीं करता है + KernelSU अभी केवल GKI कर्नल्स को सपोर्ट करता है + कर्नल + SuSFS Version + मैनेजर वर्जन + SELinux स्थिति + डिसेबल्ड (बंद) + एनफोर्सिंग + पर्मिसिव + अज्ञात + सुपरयूजर + %s मॉड्यूल चालू करने में विफल + %s मॉड्यूल बंद करने में विफल + कोई मॉड्यूल इंस्टाल नहीं हुआ + मॉड्यूल + Sort (Action first) + Sort (Enabled first) + अनइंस्टॉल करें + इंस्टाल करें + इंस्टाल करें + रीबूट करें + सेटिंग + सॉफ्ट रिबूट + रिकवरी में रिबूट करें + बुटलोडर में रिबूट करें + डाउनलोड में रिबूट करें + EDL मोड में रिबूट करें + हमारे बारे में + क्या आप सच में मॉड्यूल %s को अनइंस्टॉल करना चाहते हैं\? + %s अनइंस्टॉल सफल हुआ + %s अनइंस्टल करने में असफल + वर्जन + निर्माता + रिफ्रेश + सिस्टम एप्प दिखाए + सिस्टम एप्प छिपाए + लॉग भेजे + सेफ मोड + प्रभाव में होने के लिए रीबूट करें + मॉड्यूल बंद कर दिए गए हैं क्योंकि यह मैजिक के साथ टकरा रहे है! + KernelSU सीखें + https://kernelsu.org/guide/what-is-kernelsu.html + जानें कि KernelSU कैसे स्थापित करें और मॉड्यूल का उपयोग कैसे करें + हमें प्रोत्साहन दें + KernelSU मुफ़्त और ओपन सोर्स और हमेशा रहेगा। हालाँकि आप दान देकर हमें दिखा सकते हैं कि आप संरक्षण करते हैं। + Join our %2$s channel]]> + डिफॉल्ट + टेम्पलेट + कस्टम + प्रोफाइल का नाम + समूह + क्षमताएं + SELinux context + मॉड्यूल्स अनमाउंट करें + %s के लिए ऐप प्रोफ़ाइल अपडेट करने में विफल + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + डिफ़ॉल्ट रूप से मॉड्यूल अनमाउन्ट करें + ऐप प्रोफाइल में \"अनमाउंट मॉड्यूल\" के लिए ग्लोबल डिफ़ॉल्ट वैल्यू। यदि चालू किया गया है, तो यह एप्लीकेशंस के लिऐ सिस्टम के सभी मॉड्यूल मोडिफिकेशन को हटा देगा जिनकी प्रोफ़ाइल सेट नहीं है। + इस विकल्प को चालू करने से KernelSU को इस एप्लिकेशन के लिए मॉड्यूल द्वारा किसी भी मोडिफाइड फ़ाइल को रिस्टोर करें। + डोमेन + नियम + अपडेट + %s मॉड्यूल डाउनलोड हो रहा है + %s की डाउनलोडिंग स्टार्ट करें + नया वर्जन: %s उपलब्ध है,अपग्रेड के लिए क्लिक करें + लॉन्च करें + जबर्दस्ती बंद करें + फिर से चालू करें + %s के लिए SELinux नियमों को अपटेड करने में विफल + क्या बदलाव हुए है + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + लॉग सहेजें + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-hr/strings.xml b/manager/app/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..ab68841 --- /dev/null +++ b/manager/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,362 @@ + + + Početna + Nije instalirano + Kliknite da instalirate + Radi + Verzija: %s + Nepodržano + KernelSU samo podržava GKI kernele sad + Kernel + SuSFS Version + Verzija Voditelja + SELinux stanje + Isključeno + U Provođenju + Permisivno + Nepoznato + Superkorisnik + Neuspješno uključivanje module: %s + Neuspješno isključivanje module: %s + Nema instaliranih modula + Modula + Sort (Action first) + Sort (Enabled first) + Deinstalirajte + Instalirajte + Instalirajte + Ponovno pokrenite + Postavke + Lagano Ponovno pokretanje + Ponovno pokrenite u Oporavu + Ponovno pokrenite u Pogonski Učitavalac + Ponovno pokrenite u Preuzimanje + Ponovo pokrenite u EDL + O + Jeste li sigurni da želite deinstalirati modulu %s\? + %s deinstalirana + Neuspješna deinstalacija: %s + Verzija + Autor + Osvježi + Prikažite sistemske aplikacije + Sakrijte sistemske aplikacije + Pošaljite Izvještaj + Sigurnosni mod + Ponovno pokrenite da bi proradilo + Module su isključene jer je u sukobu sa Magisk-om! + Naučite KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Naučite kako da instalirate KernelSU i da koristite module + Podržite Nas + KernelSU je, i uvijek če biti, besplatan, i otvorenog izvora. Možete nam međutim pokazati da vas je briga s time da napravite donaciju. + Join our %2$s channel]]> + Zadano + Šablon + Prilagođeno + Naziv profila + Grupe + Sposobnosti + SELinux kontekst + Umount module + Ažuriranje Profila Aplikacije za %s nije uspjelo + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Umount module po zadanom + Globalna zadana vrijednost za \"Umount module\" u Profilima Aplikacije. Ako je omogućeno, uklonit će sve izmjene modula na sistemu za aplikacije koje nemaju postavljen Profil. + Uključivanjem ove opcije omogućit će KernelSU-u da vrati sve izmjenute datoteke od strane modula za ovu aplikaciju. + Domena + Pravila + Ažuriranje + Preuzimanje module: %s + Započnite sa preuzimanjem: %s + Nova verzija: %s je dostupna, kliknite da preuzmete + Pokrenite + Prisilno Zaustavite + Resetujte + Neuspješno ažuriranje SELinux pravila za: %s + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + Spremi Zapise + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-hu/strings.xml b/manager/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..ba3813a --- /dev/null +++ b/manager/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,362 @@ + + + Kezdőlap + Nincs telepítve + Kattintson a telepítéshez + Működik + Verzió: %s + Nem támogatott + A KernelSU jelenleg csak GKI kerneleket támogat + Kernel + SuSFS Version + Alkalmazás verziója + SELinux állapot + Letiltva + Kényszerített + Engedélyezett + Ismeretlen + Superuser + Nem sikerült engedélyezni a következő modult: %s + Nem sikerült letiltani a következő modult: %s + Nincs telepített modul + Modulok + Sort (Action first) + Sort (Enabled first) + Eltávolítás + Telepítés + Telepítés + Újraindítás + Beállítások + Rendszerfelület újraindítása + Újraindítás recovery-módba + Újraindítás bootloader-módba + Újraindítás letöltő módba + Újraindítás EDL-be + Névjegy + Biztos benne hogy eltávolítja a következő modult: %s? + %s eltávolítva + Nem sikerült eltávolítani: %s + Verzió + Készítő + Frissítés + Rendszeralkalmazások megjelenítése + Rendszeralkalmazások elrejtése + Naplók küldése + Biztonságos mód + Indítsa újra a készüléket a változások érvényesítéséhez + A modulok nem érhetők el a Magiskkel való ütközés miatt! + Tudjon meg többet a KernelSU-ról + https://kernelsu.org/guide/what-is-kernelsu.html + Ismerje meg a KernelSU telepítését és a modulok használatát + Támogasson minket + A KernelSU ingyenes, nyílt forráskódú és mindig is az lesz. Ön azonban adományozással megmutathatja, hogy törődik a projekttel. + Join our %2$s channel]]> + Alapértelmezett + Sablon + Egyedi + Profil neve + Csoportok + Jogosultságok + SELinux kontextus + Modulok leválasztása + Nem sikerült frissíteni az App Profilt ehhez: %s + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Modulok leválasztása alapértelmezetten + A \"Modulok leválasztása\" globális alapértelmezett értéke az App Profile-ban. Ha engedélyezve van, eltávolít minden modulmódosítást a rendszerből azon alkalmazások esetében, amelyeknek nincs profilja beállítva. + Ha engedélyezi ezt az opciót, a KernelSU visszaállíthatja az alkalmazás moduljai által módosított fájlokat. + Tartomány + Szabályok + Frissítés + Modul letöltése: %s + Letöltés indítása: %s + Elérhető az új, %s verzió, kattintson a frissítéshez. + Indítás + Kényszerített leállítás + újraindítás + Nem sikerült frissíteni az SELinux szabályokat a következőhöz: %s + Változások + App Profile sablon + Az App Profile helyi és online sablonjának kezelése + Sablon készítése + Sablon szerkesztése + ID + Hibás sablon ID + Név + Leírás + Mentés + Törlés + Sablon megtekintése + Csak olvasható + A sablon ID már létezik! + Import/Export + Importálás a vágólapról + Exportálás a vágólapról + Nem található helyi sablon az exportáláshoz! + Sikeresen importálva + Online sablonok szinkronizálása + A sablon mentése sikertelen + A vágólap üres! + A változásnapló lekérése nem sikerült: %s + Frissítés ellenőrzése + Automatikusan keressen frissítéseket az alkalmazás megnyitásakor + A root jog megadása sikertelen! + Művelet + Close + WebView hibakeresés engedélyezése + A WebUI hibakeresésére használható, csak szükség esetén engedélyezze. + Közvetlen telepítés (Ajánlott) + Fájl kiválasztása + Telepítés inaktív helyre (OTA után) + Az eszköze **KÉNYSZERÍTETTEN** a jelenleg inaktív helyről fog indulni újraindítás után!\nCsak az OTA befejezése után használja.\nFolytatja? + Következő + %1$s partíció képfájl ajánlott + KMI kiválasztása + Eltávolítás + Ideiglenes eltávolítás + Végleges eltávolítás + Eredeti képfájl visszaállítása + A KernelSU ideiglenes eltávolítása, az eredeti állapot visszaállítása a következő újraindítás után. + A KernelSU eltávolítása (root és az összes modul) teljesen és véglegesen. + Állítsa vissza a gyári képfájlt (ha létezik biztonsági mentés). Általában OTA előtt használják. Ha a KernelSU-t szeretné eltávolítani, használja a végleges eltávolítás opciót. + Telepítés + Sikeres telepítés + Sikertelen telepítés + Kiválasztott LKM: %s + Naplók mentése + Mentett naplók + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-idn/strings.xml b/manager/app/src/main/res/values-idn/strings.xml new file mode 100644 index 0000000..3dbe03e --- /dev/null +++ b/manager/app/src/main/res/values-idn/strings.xml @@ -0,0 +1,537 @@ + + + Beranda + Tidak Terpasang + Klik untuk Memasang + Berfungsi + Versi: %s + Tidak Didukung + Driver KernelSU tidak terdeteksi di kernel Anda. Mungkin Anda menggunakan kernel yang salah. + Versi Kernel + Versi SuSFS + Versi Manajer + Status SELinux + Dinonaktifkan + Ditegakkan + Permisi + Tidak Diketahui + Superuser + Gagal mengaktifkan modul: %s + Gagal menonaktifkan modul: %s + Tidak ada modul terpasang + Modul + Urutkan (Aksi Terlebih Dahulu) + Urutkan (Aktif Terlebih Dahulu) + Copot Pemasangan + Pasang + Pasang + Muat Ulang + Pengaturan + Muat Ulang Lunak + Muat Ulang ke Recovery + Muat Ulang ke Bootloader + Muat Ulang ke Mode Download + Muat Ulang ke Mode EDL + Tentang + Apakah Anda yakin ingin mencopot pemasangan modul %s? + %s telah dicopot + Gagal mencopot pemasangan: %s + Versi + Penulis + Segarkan + Tampilkan Aplikasi Sistem + Sembunyikan Aplikasi Sistem + Kirim Log + Mode Aman + Muat ulang untuk menerapkan + Modul tidak tersedia karena konflik dengan Magisk! + Pelajari tentang KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Pelajari cara memasang KernelSU dan menggunakan modul + Dukung Kami + KernelSU bersifat gratis dan open source, sekarang dan selamanya. Namun, Anda dapat menunjukkan dukungan Anda dengan melakukan donasi. + Gabung ke saluran %2$s kami]]> + Profil Aplikasi + Bawaan + Templat + Khusus + Nama Profil + Grup + Kemampuan + Konteks SELinux + Lepas Kait Modul + Gagal memperbarui profil aplikasi untuk %s + Versi KernelSU saat ini %s terlalu rendah untuk menjalankan manajer dengan benar. Harap perbarui ke versi %s atau yang lebih tinggi! + Lepas kait modul secara bawaan + Nilai bawaan global untuk \"Lepas Kait Modul\" dalam profil aplikasi. Jika diaktifkan, ini akan menghapus semua perubahan sistem yang dibuat oleh modul untuk aplikasi tanpa profil yang ditetapkan. + Mengaktifkan opsi ini akan memungkinkan KernelSU untuk memulihkan file yang diubah oleh modul untuk aplikasi ini. + Domain + Aturan + Perbarui + Mengunduh modul: %s + Memulai pengunduhan: %s + Versi baru %s tersedia, klik untuk memperbarui. + Jalankan + Paksa Hentikan + Jalankan Ulang + Gagal memperbarui aturan SELinux untuk %s + Catatan Perubahan + Templat Profil Aplikasi + Kelola templat profil aplikasi lokal dan daring + Buat Templat + Edit Templat + ID + ID Templat Tidak Valid + Nama + Deskripsi + Simpan + Hapus + Lihat Templat + Hanya Baca + ID Templat sudah ada! + Impor/Ekspor + Impor dari Papan Klip + Ekspor ke Papan Klip + Tidak ditemukan templat lokal untuk diekspor! + Berhasil diimpor + Sinkronkan Templat Daring + Gagal menyimpan templat + Papan klip kosong! + Gagal memuat catatan perubahan: %s + Periksa Pembaruan + Secara otomatis memeriksa pembaruan saat membuka aplikasi + Gagal memberikan hak akses root! + Aksi + Tutup + Aktifkan Debug WebView + Dapat digunakan untuk mendebug WebUI. Harap aktifkan hanya jika diperlukan. + Pemasangan Langsung (Disarankan) + Pilih Gambar untuk Dipatch + Pasang ke Slot Tidak Aktif (Setelah OTA) + Perangkat Anda akan **DIPAKSA** untuk boot ke slot tidak aktif saat ini setelah reboot! +Gunakan opsi ini hanya setelah OTA selesai. +Lanjutkan? + Lanjut + Disarankan gambar partisi %1$s + Pilih KMI + Copot Pemasangan + Copot Pemasangan Sementara + Copot Pemasangan Permanen + Pulihkan Gambar Bawaan + Copot pemasangan KernelSU secara sementara, kembalikan ke keadaan awal setelah reboot berikutnya. + Copot pemasangan KernelSU secara lengkap dan permanen (Root dan semua modul). + Pulihkan gambar bawaan pabrik (jika cadangan tersedia), biasanya digunakan sebelum OTA; jika ingin mencopot KernelSU, gunakan \"Copot Pemasangan Permanen\". + Mem-flash + Flash Berhasil + Flash Gagal + LKM Terpilih: %s + Simpan Log + Log Disimpan + + Konfirmasi pemasangan modul %1$s? + modul tidak dikenal + + Konfirmasi Pemulihan Modul + Operasi ini akan menimpa semua modul yang ada. Lanjutkan? + Konfirmasi + Batal + + Pencadangan Berhasil (tar.gz) + Gagal membuat cadangan: %1$s + cadangan modul + pulihkan modul + + Modul berhasil dipulihkan, perlu reboot + Gagal memulihkan: %1$s + Muat Ulang Sekarang + Kesalahan Tidak Diketahui + + Gagal mengeksekusi perintah: %1$s + + Pencadangan daftar izin berhasil + Gagal membuat cadangan daftar izin: %1$s + Konfirmasi Pemulihan Daftar Izin + Operasi ini akan menimpa daftar izin saat ini. Lanjutkan? + Daftar izin berhasil dipulihkan + Gagal memulihkan daftar izin: %1$s + Cadangkan Daftar Izin + Pulihkan Daftar Izin + Latar Belakang Aplikasi Khusus + Pilih gambar sebagai latar belakang + Transparansi Panel Navigasi + Versi Android + Model Perangkat + Pemberian hak superuser untuk %s tidak diizinkan + Nonaktifkan Kompatibilitas su + Sementara mencegah aplikasi mana pun mendapatkan hak root melalui perintah su (proses root yang ada tidak akan terpengaruh). + Apakah Anda yakin ingin memasang %1$d modul berikut? +%2$s + Pengaturan Lainnya + SELinux + Diaktifkan + Dinonaktifkan + Mode Sederhana + Menyembunyikan kartu yang tidak perlu saat diaktifkan + Sembunyikan Versi Kernel + Menyembunyikan versi kernel + Sembunyikan Informasi Lainnya + Menyembunyikan titik merah yang menunjukkan jumlah superuser, modul, dan modul KPM di halaman navigasi bawah + Sembunyikan Status SuSFS + Menyembunyikan informasi status SuSFS di halaman beranda + Sembunyikan Kartu Tautan + Menyembunyikan informasi di kartu tautan di halaman beranda + Sembunyikan Baris Tag Modul + Menyembunyikan label nama folder dan ukuran di kartu modul + Tema + Ikuti Sistem + Terang + Gelap + Hook Manual + Warna Dinamis + Warna dinamis menggunakan tema sistem + Pilih Warna Tema + Biru + Hijau + Ungu + Oranye + Merah Muda + Abu-abu + Kuning + Pasang Anykernel3 + Flash file kernel AnyKernel3 + Diperlukan hak akses root + Pembersihan Selesai + Muat ulang sekarang? + Ya + Tidak + Gagal memuat ulang + KPM + Saat ini tidak ada modul kernel yang terpasang + Versi + Penulis + Copot Pemasangan + Berhasil dicopot + Gagal mencopot + Berhasil memuat modul kpm + Gagal memuat modul kpm + Parameter + Jalankan + Versi KPM + Tutup + Fitur modul kernel berikut dikembangkan oleh KernelPatch dan dimodifikasi untuk menyertakan fitur modul kernel SukiSU Ultra + SukiSU Ultra menantikan + Berhasil + Gagal + Ke depannya, SukiSU Ultra akan menjadi cabang KSU yang relatif independen, tetapi kami tetap berterima kasih kepada KernelSU resmi, MKSU, dan lainnya atas kontribusi mereka! + Tidak Didukung + Didukung + Kernel Belum Di-patch + Kernel Belum Diaktifkan + Pengaturan Khusus + Pemasangan KPM + Muat + Tanamkan + Silakan pilih: %1$s mode pemasangan modul +Muat: Secara sementara memuat modul +Tanamkan: Secara permanen memasang ke sistem + Gagal memeriksa keberadaan file modul + Warna Tema + Jenis file tidak valid! Harap pilih file .kpm. + Copot Pemasangan + Akan mencopot KPM berikut: %s + Gunakan dua jari untuk memperbesar gambar dan satu jari untuk menyeret, untuk menyesuaikan posisi + Provisi Ulang + + Flash Selesai + + Menyiapkan… + Membersihkan file… + Menyalin file… + Mengekstrak alat flash… + Menambal skrip flash… + Mem-flash kernel… + Flash Selesai + + Pilih Slot untuk Flash + Silakan pilih slot target untuk flashing boot + Slot A + Slot B + Slot Terpilih: %1$s + Mendapatkan slot asli + Mengatur slot target + Mengembalikan slot bawaan + Slot sistem bawaan saat ini: %1$s + + Gagal menyalin + Kesalahan Tidak Diketahui + Flash Gagal + + Pemulihan/Pemasangan LKM + Flash AnyKernel3 + Versi Kernel: %1$s + Alat patch yang digunakan: %1$s + Konfigurasi + Pengaturan Aplikasi + Alat + + Aplikasi tidak ditemukan + SELinux diaktifkan + SELinux dinonaktifkan + Gagal mengubah status SELinux + Pengaturan Lanjutan + Sesuaikan Bilah Alat + Kembali + Latar belakang berhasil diatur + Latar belakang khusus dihapus + Ikon Alternatif + Ubah ikon peluncur menjadi ikon KernelSU. + Ikon diubah + + Sembunyikan Fungsi KPM + Menyembunyikan informasi dan fungsi KPM di layar utama dan panel bawah + + Pilih Mesin WebUI untuk Digunakan + Pilih Otomatis + Paksa Gunakan WebUI X + Paksa Gunakan KSU WebUI + Sisipkan Eruda ke WebUI X + Sisipkan konsol debug ke WebUI X untuk memudahkan debugging. Memerlukan debugging web diaktifkan. + + DPI yang Diterapkan + Sesuaikan kepadatan layar hanya untuk aplikasi saat ini + Kecil + Sedang + Besar + Sangat Besar + Khusus + Menerapkan Pengaturan DPI + Konfirmasi Perubahan DPI + Apakah Anda yakin ingin mengubah DPI aplikasi dari %1$d menjadi %2$d? + Aplikasi perlu dijalankan ulang agar pengaturan DPI baru diterapkan; ini tidak akan mempengaruhi bilah status sistem atau aplikasi lainnya + DPI diatur ke %1$d, akan diterapkan setelah aplikasi dijalankan ulang + + Bahasa Aplikasi + Ikuti Sistem + Pengaturan Pencahayaan Kartu + + kode kesalahan + Silakan periksa log + Memasang modul %1$d/%2$d + Gagal memasang %d modul baru + Gagal mengunduh modul + Mem-flash Kernel + + Semua + Root + Khusus + Bawaan + + Nama Naik + Nama Turun + Waktu Pemasangan (Baru) + Waktu Pemasangan (Lama) + Ukuran Turun + Ukuran Naik + Frekuensi Penggunaan + + Tidak ada aplikasi dalam kategori ini + + Tolak Hak Akses + Berikan Hak Akses + Lepas Kaitan Modul + Nonaktifkan Lepas Kaitan Modul + Perluas Menu + Ciutkan Menu + Ke Atas + Ke Bawah + Terpilih + Pilih + + Opsi Menu + Urutkan Berdasarkan + Pilih Jenis Aplikasi + + Konfigurasi SuSFS + Deskripsi Konfigurasi + Fitur ini memungkinkan Anda untuk mengonfigurasi spoofing nilai uname dan waktu build SuSFS. Masukkan nilai yang diinginkan dan klik \"Terapkan\" agar berlaku. + Nilai Uname + Silakan masukkan nilai uname khusus + Spoof Waktu Build + Silakan masukkan nilai spoof waktu build + Nilai Saat Ini: %s + Waktu Build Saat Ini: %s + Atur Ulang ke Bawaan + Terapkan + + Konfirmasi Atur Ulang + + Gagal menemukan file ksu_susfs + Gagal mengeksekusi perintah SuSFS + Kesalahan eksekusi perintah SuSFS: %s + Nilai uname dan waktu build SuSFS berhasil diatur: %s, %s + + Konfigurasi SuSFS + + Mulai Otomatis + Secara otomatis menerapkan semua konfigurasi non-bawaan saat reboot + Perlu menambahkan konfigurasi untuk mengaktifkan + Gagal mengaktifkan mulai otomatis + Gagal menonaktifkan mulai otomatis + Kesalahan konfigurasi mulai otomatis: %s + Tidak ada konfigurasi yang tersedia untuk mulai otomatis + + Pengaturan Dasar + Jalur SUS + Kaitan SUS + Coba Lepas Kait + Pengaturan Jalur + Status Fitur Diaktifkan + + Tambah Jalur SUS + Tambah Kaitan SUS + Tambah Coba Lepas Kait + Jalur SUS berhasil ditambahkan + Kesalahan: Jalur tidak ditemukan + Jalur + Jalur Kaitan + misalnya: /system/addon.d + Tidak ada jalur SUS yang dikonfigurasi + Tidak ada kaitan SUS yang dikonfigurasi + Tidak ada coba lepas kait yang dikonfigurasi + + Mode Lepas Kait + Lepas Kait Normal (0) + Lepas Kait Terpisah (1) + Normal + Terpisah + Mode: %1$s (%2$s) + Jalur coba lepas kait berhasil ditambahkan: %s + Berhasil menyimpan jalur coba lepas kait: %s + + + Atur Ulang Jalur SUS + Ini akan menghapus semua konfigurasi jalur SUS. Apakah Anda yakin ingin melanjutkan? + Atur Ulang Kaitan SUS + Ini akan menghapus semua konfigurasi kaitan SUS. Apakah Anda yakin ingin melanjutkan? + Atur Ulang Coba Lepas Kait + Ini akan menghapus semua konfigurasi coba lepas kait. Apakah Anda yakin ingin melanjutkan? + Atur Ulang Pengaturan Jalur + + Jalur Data Android + Jalur Kartu SD + Atur Jalur Data Android + Atur Jalur Kartu SD + + Menampilkan status saat ini dari fitur SuSFS yang diaktifkan + Informasi status fitur tidak ditemukan + Diaktifkan + Dinonaktifkan + + Dukungan Jalur SUS + Dukungan Kaitan SUS + Dukungan Coba Lepas Kait + Dukungan Spoof Uname + Spoof Cmdline/Bootconfig + Dukungan Open Redirect + Dukungan Logging + Kaitan Bawaan Otomatis + Kaitan Bind Otomatis + Coba Lepas Kaitan Bind Otomatis + Sembunyikan Simbol KSU SUSFS + Dukungan SUS Kstat + Fitur Toggle Mode SUS SU + + Fitur SuSFS yang Dapat Dikonfigurasi + Aktifkan Log SuSFS + Aktifkan atau nonaktifkan logging untuk SuSFS + Pengaturan Logging SuSFS + Mengaktifkan Logging SuSFS + Menonaktifkan Logging SuSFS + Perbarui JSON + URL Perbarui JSON disalin ke papan klip + + Tampilkan Informasi Modul Lebih Banyak + Tampilkan informasi modul tambahan seperti URL perbarui JSON + Lokasi Eksekusi + Lokasi Eksekusi Saat Ini: %s + Layanan + Post-FS-Data + Jalankan setelah layanan sistem dimulai + Jalankan setelah sistem file dikaitkan tetapi sebelum sistem sepenuhnya dinyalakan. Dapat menyebabkan bootloop + Informasi Slot + Lihat informasi slot boot saat ini dan salin nilainya + Slot Aktif Saat Ini: %s + Uname: %s + Waktu Build: %s + Saat Ini + Gunakan Uname + Gunakan Waktu Build + Gagal mendapatkan informasi slot + + Modul mulai otomatis SuSFS diaktifkan, jalur modul: %s + Modul mulai otomatis SuSFS dinonaktifkan + + Konfigurasi Kstat + Konfigurasi Kstat statis ditambahkan: %1$s + Konfigurasi Kstat dihapus: %1$s + Jalur Kstat ditambahkan: %1$s + Jalur Kstat dihapus: %1$s + Kstat diperbarui: %1$s + Klon Lengkap Kstat diperbarui: %1$s + Tambahkan Konfigurasi Kstat Statis + Jalur File/Direktori + Petunjuk: Anda dapat menggunakan \"default\" untuk menggunakan nilai asli + Tambah Jalur Kstat + Tambah + Atur Ulang Konfigurasi Kstat + Apakah Anda yakin ingin membersihkan semua konfigurasi Kstat? Tindakan ini tidak dapat dibatalkan. + Deskripsi Konfigurasi Kstat + • add_sus_kstat_statically: Informasi file/direktori statis + • add_sus_kstat: Tambahkan jalur sebelum bind mount, menjaga informasi asli + • update_sus_kstat: Perbarui ino target, membiarkan ukuran dan blok tidak berubah + • update_sus_kstat_full_clone: Perbarui hanya ino, membiarkan nilai asli lainnya + Konfigurasi Kstat Statis + Manajemen Jalur Kstat + Belum ada konfigurasi Kstat, klik tombol di atas untuk menambahkan + + Kontrol Penyembunyian Kaitan SUS + Kontrol perilaku penyembunyian kaitan SUS untuk proses + Sembunyikan Kaitan SUS untuk Semua Proses + Jika diaktifkan, kaitan SUS akan disembunyikan dari semua proses, termasuk proses KSU + Jika dinonaktifkan, kaitan SUS akan disembunyikan hanya dari proses non-KSU; proses KSU akan dapat melihat kaitan + Mengaktifkan penyembunyian kaitan SUS untuk semua proses + Menonaktifkan penyembunyian kaitan SUS untuk semua proses + Disarankan untuk mengatur ke nonaktif setelah layar terbuka atau pada tahap service.sh atau boot-completed.sh, karena ini seharusnya memperbaiki masalah dengan beberapa aplikasi root yang bergantung pada kaitan yang dibuat oleh proses KSU + Pengaturan Saat Ini: %s + Sembunyikan untuk Semua Proses + Sembunyikan Hanya untuk Proses Non-KSU + Mode Versi Kernel Sederhana + Aktifkan atau nonaktifkan tampilan versi kernel SukiSU sederhana + Jalur Data Android diatur ke: %s + Jalur Kartu SD diatur ke: %s + Pengaturan jalur mungkin tidak sepenuhnya berhasil, tetapi jalur SUS akan tetap ditambahkan + + Cadangan + Buat cadangan semua konfigurasi SuSFS. File cadangan akan menyertakan semua pengaturan, jalur, dan konfigurasi. + Buat Cadangan + Berhasil membuat cadangan: %s + Gagal membuat cadangan: %s + File cadangan tidak ditemukan + Format file cadangan tidak valid + Versi cadangan tidak cocok, tetapi akan dicoba untuk dipulihkan + Pulihkan + Pulihkan konfigurasi SuSFS dari file cadangan. Ini akan menimpa semua pengaturan saat ini. + Pilih File Cadangan + Konfigurasi berhasil dipulihkan dari cadangan yang dibuat %s pada perangkat: %s + Gagal memulihkan: %s + Konfirmasi Pemulihan + Ini akan menimpa semua konfigurasi SuSFS saat ini. Apakah Anda yakin ingin melanjutkan? + Pulihkan + Tanggal Cadangan: %s + Perangkat: %s + Versi: %s + Status Terkunci + Timpa properti status bootloader dalam layanan late_start + Bersihkan Sisa-sisa + Bersihkan file dan direktori sisa dari berbagai modul dan alat (dapat menyebabkan penghapusan yang tidak disengaja, kehilangan data, dan gagal boot, gunakan dengan hati-hati) + diff --git a/manager/app/src/main/res/values-in/strings.xml b/manager/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..64027ac --- /dev/null +++ b/manager/app/src/main/res/values-in/strings.xml @@ -0,0 +1,697 @@ + + + Beranda + Tidak terinstal + Klik untuk menginstal + Berfungsi + Versi: %s + Tidak didukung + KernelSU saat ini hanya mendukung kernel GKI + Kernel + Versi SuSFS + Versi manager + Status SELinux + Nonaktif + Enforcing + Permisif + Tidak diketahui + SuperUser + Gagal mengaktifkan modul: %s + Gagal menonaktifkan modul: %s + Tidak ada modul yang terpasang + Modul + Urut (Tindakan pertama) + Urut (Diaktifkan terlebih dahulu) + Hapus + Instal + Instal + Reboot + Pengaturan + Soft Reboot + Reboot ke Recovery + Reboot ke Bootloader + Reboot ke Download + Reboot ke EDL + Tentang + Yakin menghapus modul %s? + %s berhasil dihapus + Gagal menghapus: %s + Versi + Oleh + Muat ulang + Tampilkan aplikasi sistem + Sembunyikan aplikasi sistem + Kirim Log + Mode aman + Reboot agar berfungsi + Konflik dengan Magisk, fungsi modul ditiadakan! + Pelajari KernelSU + https://kernelsu.org/id_ID/guide/what-is-kernelsu.html + Pelajari cara instal KernelSU dan menggunakan modul + Dukung Kami + KernelSU akan selalu menjadi aplikasi gratis dan terbuka. Anda dapat memberikan donasi sebagai bentuk dukungan. + Gabung dengan kami di saluran %2$s]]> + Bawaan + Templat + Khusus + Nama profil + Kelompok + Kemampuan + Konteks SELinux + Umount Modul + Gagal membarui Profil pada %s + Versi KernelSU saat ini %s terlalu rendah untuk menjalankan manager dengan baik. Harap tingkatkan ke versi %s atau yang lebih tinggi! + Melepas Modul secara bawaan + Menggunakan \"Umount Modul\" secara universal pada Profil Aplikasi. Jika diaktifkan, akan menghapus semua modifikasi sistem untuk aplikasi yang tidak memiliki set profil. + Aktifkan opsi ini agar KernelSU dapat memulihkan kembali berkas termodifikasi oleh modul pada aplikasi ini. + Domain + Aturan + Membarui + Mengunduh modul: %s + Mulai mengunduh: %s + Tersedia versi terbaru %s, Klik untuk membarui. + Jalankan + Paksa berhenti + Mulai ulang + Gagal membarui aturan SELinux pada: %s + Catatan Perubahan + Templat Profil Aplikasi + Atur templat Profil yang lokal dan daring + Buat templat + Edit templat + ID + ID template tidak valid + Nama + Deskripsi + Simpan + Hapus + Lihat templat + readonly + ID templat sudah ada! + Impor/Ekspor + Impor dari papan klip + Ekspor ke papan klip + Tidak ditemukan templat lokal untuk diekspor! + Berhasil diimpor + Sinkronkan templat daring + Gagal menyimpan templat + Papan klip kosong! + Gagal mengambil Changelog: %s + Cek terbaru + Cek terbaru setiap membuka aplikasi + Gagal memberikan akses root! + Tindakan + Tutup + Pengawakutuan WebView + Dapat digunakan untuk men-debug WebUI. Harap aktifkan hanya bila diperlukan. + Instal langsung (rekomendasi) + Pilih berkas + Instal ke slot nonaktif (setelah OTA) + Gawai akan **DIPAKSA** untuk but ke slot nonaktif! +\nHANYA gunakan setelah proses OTA selesai. +\nLanjutkan? + Selanjutnya + Gunakan berkas LKM lokal + Hanya berkas .ko yang didukung + %1$s image partisi terekomendasi + Pilih KMI + Hapus + Hapus sementara + Hapus permanen + Pulihkan image bawaan + Sementara menghapus KernelSU, memulihkan ke kondisi asal setelah reboot berikutnya. + Hapus permanen KernelSU (root dan modul). + Pulihkan image bawaan ROM (jika cadangan tersedia), umumnya dilakukan sebelum OTA; jika ingin menghapus KernelSU, gunakan fungsi \"Hapus permanen\". + Pasang + Pemasangan Berhasil + Pemasangan Gagal + LKM dipilih: %s + Simpan Log + Log disimpan + + konfirmasi pemasangan modul %1$s? + module tidak dikenal + + Konfirmasi pemulihan module + Operasi ini akan menimpa semua modul yang ada. Lanjutkan? + Konfirmasi + Batal + + Pencadangan berhasil (tar.gz) + Pencadangan gagal: %1$s + cadangkan modul + pulihkan modul + + Modul berhasil dipulihkan, restart diperlukan + Pemulihan gagal: %1$s + Mulai Ulang Sekarang + Kesalahan tidak diketahui + + Eksekusi perintah gagal: %1$s + + Cadangan daftar izin berhasil + Gagal mencadangkan daftar izin: %1$s + Konfirmasi Pemulihan Daftar Izin + Operasi ini akan menimpa daftar izin saat ini. Lanjutkan? + Daftar izin berhasil dipulihkan + Gagal memulihkan daftar izin: %1$s + Cadangkan Daftar Izin + Pulihkan Daftar Izin + Latar belakang kustom + Pilih gambar untuk latar belakang + NavBar transparant + Versi Android + Model Perangkat + Memberikan hak superuser kepada %s tidak diizinkan + Nonaktifkan kompatibilitas SU + Nonaktifkan sementara kemampuan aplikasi untuk mendapatkan hak akses root melalui perintah ⁠su (proses root yang sedang berjalan tidak akan terpengaruh) + Nonaktifkan pelepasan (unmount) kernel + Nonaktifkan perilaku unmount pada level kernel yang digunakan oleh KernelSU. + Aktifkan keamanan yang ditingkatkan + Aktifkan kebijakan keamanan yang lebih ketat. + Bawaan + Aktifkan sementara + Aktifkan secara permanen + Apakah Anda yakin ingin menginstal %1$d modul berikut?\n\n%2$s + Setelan lainnya + Selinux + Aktifkan + Nonaktifkan + Mode simple + Sembunyikan papan kartu di beranda + Sembunyikan versi kernel + Sembunyikan versi kernel jika namanya tidak yakin + Sembunyikan info lain + Sembunyikan notifikasi titik merah (jumlah Super User, modul, dan modul KPM) di bilah navigasi + Sembunyikan status SuSFs + Sembunyikan status susfs di halaman awal beranda + Sembunyikan status zygisk + Sembunyikan informasi implementasi Zygisk di halaman utama + Sembunyikan kartu tautan + Sembunyikan papan kartu URL di halaman awal beranda + Sembunyikan baris label modul + Sembunyikan label nama folder dan ukuran di kartu modul + Tema + Mengikuti sistem + Terang + Hitam + Hook manual + Warna dinamik + Warna dinamik, menggunakan sistem tema + Pilih warna tema + Biru + Hijau + Ungu + Oren + Ping + Abu + Kuning + Memasang Anykernel3 + Memasang file kernel AnyKernel3 + Butuh izin root + Pembersihan selesai + Apakah ingin restart sekarang? + Iya + Tidak + Mulai ulang gagal + KPM + Tidak ada modul kernel yang terpasang saat ini + Versi + Pembuat + Uninstal + Berhasil di Uninstal + Gagal Uninstal + Memuat module KPM berhasil + Memuat module KPM gagal! + Parameter + Eksekusi + Versi KPM + Tutup + Fungsi-fungsi modul kernel berikut dikembangkan oleh KernelPatch dan dimodifikasi untuk menyertakan fungsi modul kernel dari SukiSU Ultra + Antusias Untuk SukiSU Ultra + Sukses + Gagal + SukiSU Ultra akan menjadi cabang KSU yang relatif independen di masa mendatang, tetapi kami tetap menghargai KernelSU dan MKSU resmi dan sebagainya atas kontribusi mereka! + Tidak Mendukung + Mendukung + Kernel belum ditambal + Kernel belum dikonfigurasi + Pengaturan kostum + Instalasi KPM + Muat + Sematkan + Silakan pilih: %1\$s Mode Instalasi Modul \n\nMuat: Memuat sementara modul \nSematkan: Menginstal secara permanen ke dalam sistem + Gagal memeriksa keberadaan file modul + Warna Tema + Format file tidak sesuai. Silakan pilih file dengan format .kpm. + Menghapus instalan + KPM berikut akan diuninstall: %s + Gunakan dua jari untuk memperbesar gambar, dan satu jari untuk menggeser mengatur posisi + Reprovisi + + Flash Selesai + + Mempersiapkan… + Membersihkan Berkas... + Menyalin file... + Mengekstrak alat flash… + Memperbaiki skrip flash… + Mem-flash kernel… + Flash selesai + + Pilih Slot Flash + Silakan pilih slot target untuk flash boot + Slot A + Slot B + Slot yang dipilih: %1$s + Mendapatkan slot asli + Mengatur slot yang ditentukan + Pulihkan Slot Default + Slot default sistem saat ini:%1$s + + Menyalin gagal + Kesalahan yang tidak diketahui + Flash gagal + + Perbaikan/pemasangan LKM + Mem-flash AnyKernel3 + Versi kernel: %1$s + Menggunakan alat perbaikan:%1$s + Konfigurasi + Pengaturan Aplikasi + Alat-Alat + + Aplikasi tidak ditemukan + SELinux Dinyalakan + SELinux Dimatikan + Perubahan Status SELinux Gagal + Pengaturan Lanjutan + Kustomisasi toolbar + Kembali + Set latar belakang berhasil + Latar belakang khusus yang dihapus + Ubah ikon + Ubah ikon peluncur aplikasi ke ikon KernelSU + Ikon dirubah + + Tampilkan fungsi KPM + Tampilkan fungsi informasi KPM dan menu KPM di bilah navigasi + + Pilih jenis webUI untuk digunakan + Otomatis memilih + Paksa menggunakan WebUI X + Penggunaan wajib KSU WebUI + Suntik Eruda ke WebUI X + Suntikkan konsol debug ke dalam WebUI X untuk mempermudah proses debugging. Memerlukan pengaktifan web debugging. + + Ubah DPI + Pengaturan DPI hanya untuk aplikasi ini saja + Kecil + Sedang + Besar + Jumbo + Kustomisasi + Terapkan setelan DPI + Konfirmasi perubahan DPI + Apa kamu yakin ingin merubah DPI aplikasi dari %1$d ke %2$d? + Aplikasi membutuhkan restar untuk menerapkan opsi DPI ini, perubahan ini tidak mengganggu DPI sistem + DPI telah di rubah ke %1$d, efektif setelah aplikasi di restar + + Bahasa Aplikasi + Mengikuti sistem + Penyesuaian Kegelapan Kartu + + Kode error + Silahkan periksa log + Modul yang dipasang %1$d/%2$d + %d Gagal memasang modul baru + Download modul gagal + Memasang Kernel + + Semua + Akar + Kostum + Bawaan + + Urutan naik nama + Urutan turun nama + Waktu pemasangan (baru) + Waktu pemasangan (lama) + Urutan turun ukuran + Urutan naik ukuran + Frekuensi penggunaan + + Tidak ada aplikasi dalam kategori ini + + Penolakan otorisasi + Otorisasi + Melepas Pemasangan Modul + Nonaktifkan pelepasan pemasangan modul + Luaskan menu + Tutup menu + Atas + Bawah + Dipilih + pilihan + + Opsi Menu + Urut berdasarkan + Pilihan Jenis Aplikasi + + Konfigurasi SuSFS + Deskripsi Konfigurasi + Fitur ini memungkinkan Anda menyesuaikan nilai uname SuSFS dan spoofing waktu build. Masukkan nilai yang ingin Anda atur lalu klik Terapkan untuk memproses perubahan. + Nilai Uname + Silakan masukkan nilai uname khusus + Spoofing Waktu membangun + Masukkan nilai spoofing waktu membangun + Nilai saat ini: %s + Waktu membangun saat ini: %s + Setel Ulang ke Default + Terapkan + + Konfirmasi Setel Ulang + + File ksu_susfs tidak ditemukan + Eksekusi perintah SUSFS gagal + Gagal menjalankan perintah SUSFS: %s + Berhasil atur uname dan waktu build SUSFS: %s, %s + + Konfigurasi SUSFS + + Mulai Otomatis + Terapkan semua konfigurasi non-default secara otomatis saat mulai ulang + Perlu tambahan konfigurasi untuk mengaktifkan + Gagal mengaktifkan mulai otomatis + Gagal menonaktifkan mulai otomatis + Kesalahan konfigurasi mulai otomatis: %s + Tidak ada konfigurasi yang tersedia untuk mulai otomatis + + Pengaturan Dasar + Jalur SUS + Pemasangan SUS + Coba Umount + Pengaturan Path + Status Fitur yang Diaktifkan + + Tambahkan Jalur SUS + Tambahkan Pemasangan SUS + Tambahkan Coba Umount + Jalur SUS berhasil ditambahkan + Kesalahan jalur tidak ditemukan + Jalur + Jalur Pemasangan + contoh: /system/addon.d + Tidak ada jalur SUS yang dikonfigurasi + Tidak ada pemasangan SUS yang dikonfigurasi + Tidak ada coba umount yang dikonfigurasi + + Mode Umount + Umount Normal (0) + Umount Lepas (1) + Normal + Lepas + Mode: %1$s (%2$s) + Jalur coba umount berhasil ditambahkan: %s + Jalur coba umount berhasil disimpan: %s + + + Setel Ulang Jalur SUS + Ini akan menghapus semua konfigurasi jalur SUS. Apakah Anda yakin ingin melanjutkan? + Setel Ulang Pemasangan SUS + Ini akan menghapus semua konfigurasi mount SUS. Apakah Anda yakin ingin melanjutkan? + Setel Ulang Coba Umount + Ini akan menghapus semua konfigurasi umount. Apakah Anda yakin ingin melanjutkan? + Setel Ulang Pengaturan Jalur + + Jalur Data Android + Jalur SD Card + Atur Jalur Data Android + Atur Jalur SD Card + + Tampilkan status fitur SuSFS yang saat ini diaktifkan + Tidak ditemukan informasi status fitur + Diaktifkan + Dinonaktifkan + + Dukungan Jalur SUS + Dukungan Pemasangan SUS + Dukungan Coba Umount + Dukungan Spoof uname + Spoof Cmdline/Bootconfig + Dukungan Pengalihan Terbuka + Dukungan Logging + Pemasangan Default Otomatis + Pemasangan Bind Otomatis + Coba Umount Bind Mount Otomatis + Sembunyikan Simbol KSU SUSFS + Dukungan SUS Kstat + Fungsi pengalihan mode SUS SU + + Fitur SuSFS yang Dapat Dikonfigurasi + Aktifkan Log SuSFS + Aktifkan atau nonaktifkan logging untuk SuSFS + Konfigurasi Logging SuSFS + Mengaktifkan Logging SuSFS + Menonaktifkan logging SuSFS + Perbarui JSON + URL Pembaruan JSON disalin ke papan klip + + Tampilkan info modul lainnya + Pajang info modul tambahan seperti URL pembaruan JSON + Lokasi Eksekusi + Lokasi eksekusi saat ini: %s + Layanan + Post-FS-Data + Eksekusi setelah layanan sistem dimulai + Eksekusi setelah sistem file dipasang tetapi sebelum sistem sepenuhnya boot, Dapat menyebabkan boot loop + Informasi Slot + Lihat informasi slot boot saat ini dan salin nilai + Slot Aktif Saat Ini: %s + Uname: %s + Waktu Build: %s + Saat Ini + Gunakan Uname + Gunakan Waktu Build + Tidak dapat mengambil informasi slot + + Modul autostart SuSFS diaktifkan, jalur modul: %s + Modul autostart SuSFS dinonaktifkan + + Konfigurasi Kstat + Konfigurasi statis Kstat ditambahkan: %1$s + Konfigurasi Kstat dihapus: %1$s + Jalur Kstat ditambahkan: %1$s + Jalur Kstat dihapus: %1$s + Kstat diperbarui: %1$s + Kstat full clone diperbarui: %1$s + Tambahkan Konfigurasi Statis Kstat + Jalur File/Direktori + Petunjuk: Anda dapat menggunakan ”default“ untuk menggunakan nilai asli + Tambahkan Jalur Kstat + Tambahkan + Setel Ulang Konfigurasi Kstat + Apakah Anda yakin ingin menghapus semua konfigurasi Kstat? Tindakan ini tidak dapat dibatalkan. + Deskripsi Konfigurasi Kstat + • add_sus_kstat_statically: Info stat statis file/direktori + • add_sus_kstat: Tambahkan jalur sebelum bind mount, menyimpan info stat asli + • update_sus_kstat: Perbarui target ino, pertahankan ukuran dan blok tidak berubah + • update_sus_kstat_full_clone: Perbarui ino saja, pertahankan nilai asli lainnya + Konfigurasi Statis Kstat + Manajemen Jalur Kstat + Belum ada konfigurasi Kstat, klik tombol di atas untuk menambahkan + + Kontrol Penyembunyian Pemasangan SUS + Kontrol perilaku penyembunyian pemasangan SUS untuk proses + Sembunyikan pemasangan SUS untuk semua proses + Saat diaktifkan, pemasangan SUS akan disembunyikan dari semua proses, termasuk proses KSU + Saat dinonaktifkan, pemasangan SUS hanya akan disembunyikan dari proses non-KSU, proses KSU dapat melihat pemasangan + Mengaktifkan penyembunyian pemasangan SUS untuk semua proses + Menonaktifkan penyembunyian pemasangan SUS untuk semua proses + Disarankan untuk menonaktifkan setelah layar tidak terkunci, atau selama tahap service.sh atau boot-completed.sh, karena ini seharusnya memperbaiki masalah pada beberapa aplikasi root yang bergantung pada pemasangan yang dipasang oleh proses KSU + Pengaturan saat ini: %s + Sembunyikan untuk semua proses + Sembunyikan hanya untuk proses non-KSU + Mode Ringkas Versi Kernel + Aktifkan atau nonaktifkan mode bersih yang ditampilkan oleh versi kernel SukiSU + Jalur Data Android telah diatur ke: %s + Jalur SD card telah diatur ke: %s + Penyiapan jalur mungkin tidak sepenuhnya berhasil, tetapi jalur SUS akan terus ditambahkan + + Backup + Buat backup dari semua konfigurasi SuSFS. File backup akan mencakup semua pengaturan, jalur, dan konfigurasi. + Buat Backup + Backup berhasil dibuat: %s + Pembuatan backup gagal: %s + File backup tidak ditemukan + Format file backup tidak valid + Versi backup tidak cocok, tetapi akan mencoba memulihkan + Pulihkan + Pulihkan konfigurasi SuSFS dari file backup. Ini akan menimpa semua pengaturan saat ini. + Pilih File Backup + Konfigurasi berhasil dipulihkan dari backup yang dibuat pada %s dari perangkat: %s + Pemulihan gagal: %s + Konfirmasi Pemulihan + Ini akan menimpa semua konfigurasi SuSFS saat ini. Apakah Anda yakin ingin melanjutkan? + Pulihkan + Tanggal Backup: %s + Perangkat: %s + Versi: %s + Status kunci + Timpa atribut status penguncian bootloader dalam mode layanan late_start + Bersihkan Residu + Bersihkan file dan direktori sisa dari berbagai modul dan alat (mungkin terhapus secara tidak sengaja, mengakibatkan kehilangan dan gagal memulai, gunakan dengan hati-hati) + Edit Jalur SUS + Edit Pemasangan SUS + Edit Coba Umount + Edit Konfigurasi Statis Kstat + Edit Jalur Kstat + Simpan + Edit + Hapus + Perbarui + Pembaruan konfigurasi Kstat + Pembaruan jalur Kstat + Pembaruan full clone Susfs + Lepas Layanan Isolasi Zygote + Aktifkan opsi ini untuk melepaskan titik pemasangan layanan isolasi Zygote saat sistem mulai + Lepas layanan isolasi Zygote diaktifkan + Lepas layanan isolasi Zygote dinonaktifkan + Jalur Aplikasi + Jalur lainnya + Lainnya + Aplikasi + Tambahkan Jalur Aplikasi + Versi pustaka SuSFS tidak cocok, kernel: %1$s vs manajer: %2$s. Disarankan untuk memperbarui kernel atau manajer + Peringatan + Cari Aplikasi + %1$d aplikasi dipilih + %1$d aplikasi sudah ditambahkan + Semua aplikasi telah ditambahkan + Konfigurasi Tanda Tangan Dinamis + Diaktifkan (Ukuran: %s) + Dinonaktifkan + Aktifkan Tanda Tangan Dinamis + Ukuran Tanda Tangan + Hash Tanda Tangan + Hash harus 64 karakter heksadesimal + Konfigurasi tanda tangan dinamis berhasil diatur + Gagal mengatur konfigurasi tanda tangan dinamis + Konfigurasi tanda tangan tidak valid + Tanda tangan dinamis dinonaktifkan + Gagal membersihkan tanda tangan dinamis + Dinamis + Tanda Tangan %1$d + Tidak diketahui + Manajer Aktif + Tidak ada manajer aktif + SukiSU + Implementasi Zygisk + + Jalur Loop SUS + Tambahkan Jalur Loop SUS + Edit Jalur Loop SUS + Jalur loop SUS berhasil ditambahkan: %1$s + Jalur loop SUS dihapus: %1$s + Jalur loop SUS diperbarui: %1$s -> %2$s + Tidak ada jalur loop SUS yang dikonfigurasi + Setel Ulang Jalur Loop + Apakah Anda yakin ingin menghapus semua jalur loop SUS? Tindakan ini tidak dapat dibatalkan. + Jalur Loop + /data/contoh/jalur + Catatan: Hanya jalur TIDAK di dalam /storage/ dan /sdcard/ yang dapat ditambahkan melalui jalur loop. + Kesalahan: Jalur loop tidak dapat berada di dalam direktori /storage/ atau /sdcard/ + Jalur Loop + Tambahkan Jalur Loop + + Konfigurasi Jalur Loop + Jalur loop ditandai ulang sebagai SUS_PATH pada setiap startup aplikasi pengguna non-root atau layanan terisolasi. Ini membantu mengatasi masalah di mana jalur yang ditambahkan mungkin memiliki status inode direset atau inode dibuat ulang di kernel. + Palsukan log AVC + Palsukan log AVC telah diaktifkan + Palsukan log AVC telah dinonaktifkan + Dinonaktifkan: Nonaktifkan pemalsuan sus tcontext dari \'su\' yang ditampilkan di avc log di kernel\n +Diaktifkan: Aktifkan pemalsuan sus tcontext dari \'su\' dengan \'kernel\' yang ditampilkan di avc log in kernel + Catatan Penting:\n +- Secara default pada kernel nilai ini disetel ke \'0\'\n +- Mengaktifkan ini terkadang membuat pengembang lebih sulit mengidentifikasi penyebab saat melakukan debugging terkait izin atau masalah SELinux, sehingga disarankan agar pengguna menonaktifkannya saat sedang melakukan debugging + + Tervalidasi + Tanda tangan modul tervalidasi + Verifikasi Tanda Tangan + Verifikasi tanda tangan secara paksa saat modul dipasang. (Hanya tersedia untuk arsitektur ARM) + Penerbit tidak dikenal + Modul yang tidak ditandatangani mungkin tidak lengkap. Untuk melindungi perangkat Anda, pemasangan modul ini diblokir. + Modul yang tidak ditandatangani mungkin tidak lengkap. Apakah Anda ingin mengizinkan modul berikut dari penerbit tidak dikenal untuk dipasang di perangkat ini? + Jenis hook + + Patch KPM + Untuk menambahkan fitur KPM tambahan + Patch KPM + Terapkan patch KPM ke image kernel sebelum melakukan flashing + Batalkan Patch KPM + Batalkan patch KPM yang telah diterapkan sebelumnya + Patch KPM aktif + Pembatalan patch KPM diaktifkan + Mode Patch KPM + Mode Pembatalan Patch KPM + + Sedang menyiapkan Alat KPM + Menerapkan patch KPM + Membatalkan patch KPM + Menemukan berkas Image: %s + KPM berhasil diterapkan + Patch KPM berhasil dibatalkan + File berhasil direpack + + Gagal mengekstrak berkas zip + Berkas Image tidak ditemukan + Patch KPM gagal + Pembatalan patch KPM gagal + Operasi patch KPM gagal: %s + + Ikuti kernel + Gunakan kernel apa adanya tanpa perubahan dari KPM + + Daftar aplikasi pemindaian pada mode pengguna + Mengaktifkan opsi ini akan menggunakan pemindaian mode pengguna untuk daftar aplikasi, sehingga meningkatkan kestabilan. (Jika Anda mengalami masalah seperti hang saat kernel memindai daftar aplikasi, Anda dapat mencoba mengaktifkan opsi ini.) + Pemindaian Aplikasi Multi-Pengguna + Ketika diaktifkan, fitur ini akan memindai aplikasi untuk semua pengguna, termasuk profil kerja + Gagal mengatur, silakan periksa perizinan + Bersihkan Lingkungan Runtime + Bersihkan berkas runtime dan hentikan layanan pemindai + Apakah Anda yakin ingin membersihkan lingkungan runtime? Tindakan ini akan menghentikan layanan pemindai dan menghapus berkas yang terkait. + Lingkungan runtime berhasil dibersihkan + Gagal membersihkan lingkungan runtime + + Konfirmasi Instalasi + Konfirmasi Instalasi (Berkas %d) + Instal + Modul + Kernel + Tidak diketahui + Kernel tidak diketahui + Berkas tidak diketahui + Versi + Pembuat + Deskripsi + Perangkat yang didukung + + Peta SUS + Jalur Pustaka + /data/adb/modules/my_module/zygisk/arm64-v8a.so + Tambahkan Peta SUS + Sunting Peta SUS + Peta SUS berhasil ditambahkan: %1$s + Peta SUS telah dihapus: %1$s + Peta SUS telah diperbarui: %1$s -> %2$s + Tidak ada peta SUS yang dikonfigurasi + Atur ulang Peta SUS + Tindakan ini akan menghapus semua peta SUS yang telah dikonfigurasi. Tindakan ini tidak dapat dibatalkan. + Penyembunyian Peta Memori + Sembunyikan berkas nyata yang di-mmapped dari berbagai peta di /proc/self/ + + Cari + Bersihkan Log + Apakah Anda yakin ingin mengosongkan berkas log yang dipilih? Tindakan ini tidak dapat dibatalkan. + + diff --git a/manager/app/src/main/res/values-it/strings.xml b/manager/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..a33f66c --- /dev/null +++ b/manager/app/src/main/res/values-it/strings.xml @@ -0,0 +1,364 @@ + + + Home + Non installato + Clicca per installare + In esecuzione + Versione: %s + Non supportato + KernelSU ora supporta solo i kernel GKI + Kernel + SuSFS Version + Versione del manager + Stato di SELinux + Disabilitato + Enforcing + Permissive + Sconosciuto + Accesso root + Impossibile abilitare il modulo: %s + Impossibile disabilitare il modulo: %s + Nessun modulo installato + Modulo + Sort (Action first) + Sort (Enabled first) + Disinstalla + Installa + Installa + Riavvia + Impostazioni + Riavvio rapido + Riavvia in modalità Recovery + Riavvia in modalità Bootloader + Riavvia in modalità Download + Riavvia in modalità EDL + Informazioni + Sei sicuro di voler disinstallare il modulo %s? + %s disinstallato + Impossibile disinstallare: %s + Versione + Autore + Ricarica + Mostra app di sistema + Nascondi app di sistema + Invia log + Modalità provvisoria + Riavvia per applicare la modifica + I moduli sono disabilitati perché in conflitto con Magisk! + Scopri KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Scopri come installare KernelSU e utilizzare i moduli + Supportaci + KernelSU è, e sempre sarà, gratuito e open source. Puoi comunque mostrarci il tuo apprezzamento facendo una donazione. + Join our %2$s channel]]> + Predefinito + Modello + Personalizzato + Nome profilo + Gruppi + Capacità + Contesto SELinux + Scollega moduli + Aggiornamento App Profile per %s fallito + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Scollega moduli da default + Il valore predefinito per \"Scollega moduli\" in App Profile. Se attivato, rimuoverà tutte le modifiche al sistema da parte dei moduli per le applicazioni che non hanno un profilo impostato. + Attivando questa opzione permetterai a KernelSU di ripristinare ogni file modificato dai moduli per questa app. + Dominio + Regole + Aggiorna + Sto scaricando il modulo: %s + Inizia a scaricare:%s + Nuova versione: %s disponibile, tocca per aggiornare + Apri + Arresto forzato + Riavvia + Aggiornamento regole SELinux per %s fallito + Registro aggiornamenti + Modelli App Profile + Gestisci i modelli locali e remoti di App Profile + Crea modello + Modifica modello + identificatore + Identificativo modello non valido + Nome + Descrizione + Salva + Elimina + Visualizza modello + Sola lettura + L\'identificatore del modello è già in uso! + Importa/Esporta + Importa dagli appunti + Esporta negli appunti + Impossibile trovare un modello locale da esportare! + Importato con successo + Sincronizza i modelli remoti + Impossibile salvare il modello + Gli appunti sono vuoti! + Impossibile reperire il changelog: %s + Controlla aggiornamenti + Controlla automaticamente la disponibilità di aggiornamenti all\'apertura dell\'applicazione + Impossibile ottenere l\'accesso root! + Action + Close + Abilita il debug di WebView + Può essere usato per svolgere il debug di WebUI, è consigliato attivarlo solo quando necessario. + Installazione diretta (Raccomandata) + Scegli un file + Installa nello slot inattivo (dopo OTA) + Il tuo dispositivo sarà **FORZATO** ad avviarsi nello slot inattivo dopo il riavvio! +\nUsa questa opzione solo quando l\'applicazione dell\'aggiornamento OTA è terminata. +\nProcedere? + Avanti + È consigliato usare immagine della partizione %1$s + Scegli il KMI + Disinstalla + Disinstalla temporaneamente + Disinstalla permanentemente + Ripristina immagine originale del produttore + Disinstalla temporaneamente KernelSU, ripristina lo stato originale dopo il prossimo riavvio. + Disinstalla KernelSU (root e tutti i moduli) completamente e permanentemente. + Ripristina l\'immagine di fabbrica del produttore (se il backup è presente), solitamente usato prima di applicare l\'OTA; se devi disinstallare KernelSU, utilizza invece \"Disinstalla permanentemente\". + Installazione + Installazione completata + Installazione fallita + LKM selezionato: %s + Salva Registri + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-iw/strings.xml b/manager/app/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..7860f80 --- /dev/null +++ b/manager/app/src/main/res/values-iw/strings.xml @@ -0,0 +1,74 @@ + + + הפעל מחדש כדי להכניס לתוקף + למד כיצד להתקין את KernelSU ולהשתמש במודולים + לא ידוע + הצג אפליקציות מערכת + %s הוסר + הסרת טעינת מודולים + שלח לוג + מושבת + תמכו בנו + מודולים מושבתים מכיוון שהם מתנגשים עם זה של Magisk! + יומן שינויים + התרים + הפעלה מחדש למצב הורדה + טעינת מודולים כברירת מחדל + הפעלת אפשרות זו תאפשר ל-KernelSU לשחזר קבצים שהשתנו על ידי המודולים עבור יישום זה. + הפעלת המודל נכשלה: %s + עצירה בכח + הפעלה מחדש למצב EDL + איתחול + יכולת + מפעיל מודל: %s + ערך ברירת המחדל הגלובלי עבור \"טעינת מודולים\" בפרופילי אפליקציה. אם מופעל, זה יסיר את כל שינויי המודול למערכת עבור יישומים שאין להם ערכת פרופיל. + אכיפה + הקשר SELinux + ברירת מחדל + להשיק + מצב בטוח + הפעלה מחדש לריקברי + רך Reboot + שם פרופיל + KernelSU הוא, ותמיד יהיה, חינמי וקוד פתוח. עם זאת, תוכל להראות לנו שאכפת לך על ידי תרומה. + הסרה + התקנה + לחץ להתקנה + כללים + קבוצה + מודולים + יוצר + אודות + גרסה: %s + הפעלה מחדש + KernelSU תומך רק בליבת GKI כעת + סטטוס SELinux + הסתר אפליקציות מערכת + גרסה + אינו נתמך + תחום + בית + מותאם אישית + תבנית + רענון + מוריד מודל: %s + עדכון + למד אודות KernelSU + האם אתה בטוח שברצונך להסיר את התקנת המודל %s\? + הסרת התקנת %s נכשלה: + משתמש על + הגדרות + עובד + השבתת מודל %s נכשלה: + אין מודלים מותקנים + להתקין + Kernel + לא מותקן + נכשל עדכון פרופיל האפליקציה עבור %s + https://kernelsu.org/guide/what-is-kernelsu.html + נכשל עדכון כללי SELinux עבור: %s + הפעלה מחדש לבוטלאודר + גרסת מנהל + גרסה חדשה עבור: %s זמינה, לחץ כדי לשדרג + שמור יומנים + diff --git a/manager/app/src/main/res/values-ja/strings.xml b/manager/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..fdbfa06 --- /dev/null +++ b/manager/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,615 @@ + + + ホーム + 未インストール + タップでインストール + 動作中 + バージョン: %s + 非対応 + カーネルの KernelSU ドライバが未検出です。カーネルが間違っていませんか? + カーネル バージョン + SuSFS バージョン + マネージャー バージョン + SELinux のステータス + 無効 + Enforcing + Permissive + 不明 + スーパーユーザー + %s モジュールを ON にできませんでした + %s モジュールを OFF にできませんでした + モジュールがインストールされていません + モジュール + 並べ替え (アクションを優先) + 並べ替え (最初に有効) + アンインストール + インストール + インストール + 再起動 + 設定 + ソフトリブート + リカバリーで再起動 + ブートローダーで再起動 + ダウンロードモードで再起動 + EDL で再起動 + アプリについて + %s モジュールをアンインストールしますか? + %s をアンインストールしました + %s をアンインストールできませんでした + バージョン + 作者 + 更新 + システムアプリを表示 + システムアプリを非表示 + ログを送信する + セーフモード + 再起動すると有効化されます + モジュールが Magisk との競合により利用できません! + KernelSU について学ぶ + https://kernelsu.org/ja_JP/guide/what-is-kernelsu.html + KernelSU のインストール方法やモジュールの使い方を学習できます。 + 支援する + KernelSU は今後も無料でオープンソースです。ですが、寄付をして頂けると開発者への貢献になります。 + %2$s チャンネルにご参加ください

アニメキャラのスタンプ付き画像の著作権は%3$sにあり、画像の Brand Intellectual Property は%4$sによって所有され。これらのファイルを使用する前に、%5$sを遵守することに加えて、アートコンテンツを使用するために前の 2 人の作者から許可を得る必要があります。]]>
+ デフォルト + テンプレート + カスタム + プロファイル名 + グループ + ケイパビリティ + SELinux コンテキスト + モジュールのアンマウント + %s のアプリのプロファイルの更新をできませでした + 現在の KernelSU のバージョン「%s」は低すぎるため、マネージャーは正常に動作しません。バージョン「%s」以上に更新してください! + デフォルトでモジュールのマウントを解除する + アプリプロファイルの「モジュールのアンマウント」の共通となるデフォルト値です。 有効にすると、プロファイルセットを持たないアプリのシステムに対するすべてのモジュールの変更が削除されます。 + このオプションを有効にすると、KernelSU はこのアプリのモジュールによって変更されたファイルを復元できるようになります。 + ドメイン + ルール + 更新 + モジュールをダウンロード中: %s + ダウンロードを開始: %s + 最新のバージョン「%s」が利用可能です。タップしてダウンロード。 + 起動 + 強制停止 + 再起動 + SELinux ルールの更新に失敗しました %s + 変更履歴 + アプリプロファイルのテンプレート + アプリプロファイルのローカルおよびオンラインテンプレートを管理します。 + テンプレートの作成 + テンプレートの編集 + ID + 無効なテンプレート ID + 名前 + 説明 + 保存 + 消去 + テンプレートを表示 + 読み取り専用 + テンプレート ID はすでに存在します! + インポートとエクスポート + クリップボードからインポート + クリップボードからエクスポート + エクスポートするローカル テンプレートが見つかりません! + インポートが成功しました + オンラインテンプレートの同期 + テンプレートの保存に失敗しました + クリップボードが空です! + 変更ログの取得に失敗しました: %s + 更新を確認する + アプリの起動時に更新を自動で確認します。 + root の付与に失敗しました! + アクション + 閉じる + WebView デバッグを有効化する + WebUI のデバッグに使用できます。必要な場合でのみ有効化してください。 + 直接インストール (推奨) + パッチを行うイメージを選択 + 非アクティブなスロットにインストール (OTA 後) + 再起動後、デバイスは**強制的に**、現在の非アクティブスロットから起動します。 +\nこのオプションは、OTA が完了した後にのみ使用してください。 +\n続行しますか? + 次へ + %1$s のパーティションイメージを推奨します。 + KMI を選択してください + アンインストール + 一時的にアンインストールする + 完全にアンインストールする + ストックイメージを復元 + KernelSU を一時的にアンインストールし、次回の再起動後に元の状態に戻します。 + KernelSU (root およびすべてのモジュール) を完全かつ恒久的にアンインストールします。 + バックアップが存在する場合、工場出荷時のイメージを復元できます (OTA の前に使用してください)。KernelSU をアンインストールする必要がある場合は、「完全にアンインストールする」を使用してください。 + フラッシュ + フラッシュが成功しました + フラッシュに失敗しました + 選択された LKM: %s + ログを保存 + 保存されたログ + + %1$s モジュールをインストールしますか? + 不明なモジュール + + モジュールの復元を確認 + この操作によりモジュールが上書きされます。続行しますか? + 確認 + キャンセル + + バックアップが完了しました (tar.gz) + バックアップに失敗: %1$s + モジュールをバックアップ + モジュールを復元 + + モジュールは正常に復元されました、再起動が必要です + 復元に失敗: %1$s + 今すぐ再起動 + 不明なエラー + + コマンドの実行に失敗しました: %1$s + + 許可リストのバックアップが成功しました + 許可リストのバックアップに失敗: %1$s + 許可リストの復元を確認 + この操作により許可リストが上書きされます。続行しますか? + 許可リストの復元が成功しました + 許可リストの復元に失敗: %1$s + 許可リストをバックアップ + 許可リストを復元 + アプリの背景を変更 + 背景にする画像を選択してください + ナビゲーションバーの透過 + Android バージョン + デバイスモデル + 「%s」にスーパーユーザー権限を付与することはできません + su の互換性を無効化する + su コマンドを使用してアプリが root 権限を取得する動作を一時的に無効化します (既存の root プロセスは影響を受けません)。 + %1$d 個のモジュールをインストールしてもよろしいですか?\n\n%2$s + その他の設定 + SELinux + 有効 + 無効 + シンプルモード + ON にすると不要なカードを非表示にします。 + カーネル バージョンを非表示 + カーネル バージョンを非表示にします。 + その他の情報を非表示 + ナビゲーションバーページでスーパーユーザー、モジュール、KPM モジュールの数のドットを非表示にします。 + SuSFS ステータスを非表示 + ホームページ上の SuSFS ステータス情報を非表示にします。 + Zygisk のステータスを非表示 + ホームページ上の Zygisk 実装情報を非表示にします。 + リンクカードのステータスを非表示 + ホームページ上のリンクカード情報を非表示にします。 + モジュールラベルの行を非表示 + モジュールカード内のフォルダ名とサイズのラベルを非表示にします。 + テーマ + システムに従う + ライト + ダーク + 手動でフック + ダイナミックカラー + システムテーマのダイナミックカラーを使用します。 + テーマカラーを選択 + ブルー + グリーン + パープル + オレンジ + ピンク + グレー + イエロー + AnyKernel3 をインストール + AnyKernel3 カーネルファイルをフラッシュします + root 権限が必要です + スクラブが完了しました + すぐに再起動しますか? + はい + いいえ + 再起動に失敗しました + KPM + カーネルモジュールは現在インストールされていません + バージョン + 作者 + アンインストール + アンインストールに失敗しました + アンインストールに失敗しました + KPM モジュールの読み込みに成功しました + KPM モジュールの読み込みに失敗しました + パラメータ + 実行 + KPM バージョン + 閉じる + 以下のカーネルモジュール関数は KernelPatch によって開発され、SukiSU Ultra のカーネルモジュール関数を含むように変更されました + SukiSU Ultra の今後にご期待ください + 成功 + 失敗 + SukiSU Ultra は将来的に KSU から比較的に独立したブランチになりますが、公式の KernelSU や MKSU などの貢献に感謝しています! + 非対応 + 対応 + カーネルはパッチされていません + カーネルは未設定です + カスタム設定 + KPM をインストール + 読み込む + 埋め込む + 選択してください: %1\$s モジュールのインストールモード \n\n読み込む: モジュールを一時的に読み込みます\n埋め込む: システムで恒久的にインストールします + モジュールファイルが存在するか確認できません + テーマカラー + ファイルの種類が間違っています!.kpm ファイルを選択してください。 + アンインストール + 次の KPM がアンインストールされます: %s + 2 本の指で画像を拡大、1 本の指でドラッグで位置を調整します。 + 再プロビジョニング + + フラッシュが完了しました + + 準備中… + ファイルを削除中… + ファイルをコピー中… + フラッシュツールを展開中… + フラッシュスクリプトをパッチ中… + カーネルをフラッシュ中… + フラッシュが完了しました + + フラッシュ先のスロットを選択 + フラッシュする boot のターゲットスロットを選択 + スロット A + スロット B + 選択したスロット: %1$s + オリジナルのスロットを取得 + 指定するスロットを設定 + デフォルトのスロットに復元 + 現在のシステムデフォルトスロット: %1$s + + コピーに失敗しました + 不明なエラー + フラッシュに失敗しました + + LKM の修復またはインストール + AnyKernel3 をフラッシュ + カーネル バージョン: %1$s + パッチ適用ツールの使用: %1$s + 設定 + アプリの設定 + ツール + + アプリがありません + SELinux 有効 + SELinux 無効 + SELinux ステータスの変更に失敗しました + 高度な設定 + ツールバーをカスタマイズ + 戻る + 背景の設定が成功しました + カスタム背景を削除しました + 代替アイコン + ランチャーアイコンを KernelSU のアイコンに変更します。 + アイコンを変更しました + + KPM 機能を非表示 + ホームとボトムバーから KPM の情報と機能を非表示にします。 + + WebUI で使用するエンジン + 自動選択 + WebUI X の使用を強制する + KSU WebUI の使用を強制する + WebUI に Eruda をインジェクトする + デバッグを容易にするために WebUI X にデバッグコンソールを挿入します。Web デバッグが ON になっている必要があります。 + + DPI の変更を適用 + このアプリのみで画面表示密度を調整します。 + + + + 特大 + カスタマイズ + DPI の設定を適用する + DPI の変更を確認 + アプリの DPI を %1$d から %2$d に変更してもよろしいですか? + 変更した DPI 設定を適用するにはアプリを再起動する必要がありますが、システムステータスバーや他のアプリには影響しません + DPI は %1$d に変更されました。アプリの再起動後に適用されます。 + + アプリの言語 + システムに従う + カードの暗さを調整 + + エラーコード + ログを確認してください + モジュールをインストール中: %1$d/%2$d + %d モジュールのインストールに失敗しました + モデルのダウンロードに失敗しました + カーネルをフラッシュ中 + + すべて + Root + カスタム + デフォルト + + 名前の昇順 + 名前の降順 + インストール日時 (新しい) + インストール日時 (古い) + サイズの降順 + サイズの昇順 + 使用頻度 + + このカテゴリーにアプリはありません + + 権限の認証 + 認証 + モジュールのマウントを解除 + アンインストールするモジュールのマウントを無効化します。 + メニューを展開 + メニューを収納 + 上詰め + 画面下 + 選択中 + オプション + + メニューのオプション + 並べ替え + アプリタイプを選択 + + SuSFS の構成 + 構成の説明 + この機能を使用すると SuSFS の uname の値とビルド日時の偽装をカスタマイズできます。設定する値を入力後に「適用」をタップで有効になります。 + uname の値 + カスタム uname の値を入力してください + ビルド日時を偽装 + 偽装するビルド日時を入力してください + 現在の値: %s + 現在のビルド日時: %s + デフォルトにリセット + 適用 + + リセットを確認 + + ksu_susfs ファイルが見つかりません + SuSFS コマンドの実行に失敗しました + SuSFS コマンドの実行エラー: %s + SuSFS uname とビルド日時が正常に設定されました: %s - %s + + SuSFS の構成 + + 自動起動 + システムの起動時に自動で uname の構成を適用する + 有効化するには uname を構成するかパスを追加する必要があります + 自動起動の有効化に失敗しました + 自動起動の無効化に失敗しました + 自動起動の構成エラー: %s + 自動起動に利用可能な構成がありません + + 基本設定 + SUS のパス + SUS マウント + アンマウントを試す + パスの設定 + 有効な機能のステータス + + SUS パスを追加 + SUS マウントを追加 + アンマウントを試すを追加 + SUS パスが正常に追加されました + パスが見つかりません + パス + マウントのパス + 例: /system/addon.d + SUS パスが未構成です + SUS マウントが未構成です + アンマウントを試すが未構成です + + アンマウントモード + 通常のアンマウント (0) + アンマウントを分離 (1) + 通常 + 分離 + モード: %1$s (%2$s) + 追加されたパスのアンマウントに成功しました: %s + アンマウントのパスの保存に成功しました: %s + + + SUS パスをリセット + すべての SUS パスの構成が消去されます。続行してもよろしいですか? + SUS マウントをリセット + すべての SUS マウントの構成が消去されます。続行してもよろしいですか? + リセットしてアンマウントを試す + すべてのアンマウント構成がリセットされます。続行してもよろしいですか? + パスの設定をリセット + + Android データパス + SD カードのパス + Android データパスを設定 + SD カードのパスを設定 + + SuSFS で有効な機能のステータスを表示します。 + 機能のステータス情報が見つかりません + 有効 + 無効 + + SUS パスの対応 + SUS マウントの対応 + アンマウントを試すの対応 + uname 偽装の対応 + Cmdline/Bootconfig を偽装 + オープンリダイレクトの対応 + ログの対応 + 自動でデフォルトのマウント + 自動でバインドマウント + 自動でバインドマウントのアンマウントを試す + KSU SUSFS シンボルを非表示 + SUS Kstat の対応 + SUS SU モード切り替え機能 + + 構成可能な SuSFS の機能 + SuSFS のログ取得を有効化 + SuSFS のログ取得を有効化または無効化します。 + SuSFS ログ取得の構成 + SuSFS のログ取得を有効化中 + SuSFS のログ取得を無効化 + 更新用の JSON + 更新用 JSON の URL をクリップボードにコピーしました + + モジュール情報の詳細を表示 + 更新用 JSON の URL など追加の情報を表示します。 + 実行先 + 現在の実行先: %s + サービス + Post-FS-Data + システムサービスの開始後に実行 + ファイルシステムのマウント後にシステムが完全に起動する前に実行をすることで、ブートループが発生する可能性があります。 + スロット情報 + 現在のブートスロット情報の表示と値のコピーをします。 + 現在のアクティブスロット: %s + Uname: %s + ビルド日時: %s + 現在 + Uname を使用する + ビルド日時を使用する + スロット情報を取得できません + + SuSFS 自動起動モジュールが有効、モジュールのパス: %s + SuSFS 自動起動モジュールが無効 + + Kstat の構成 + Kstat の静的構成を追加しました: %1$s + Kstat の構成を削除しました: %1$s + Kstat パスを追加しました: %1$s + Kstat パスを削除しました: %1$s + Kstat が更新されました: %1$s + Kstat のフルクローンが更新されました: %1$s + Kstat 静的構成を追加 + ファイルまたはディレクトリのパス + ヒント: オリジナルの値を使用するには「default」を使用します + Kstat のパスを追加 + 追加 + Kstat の構成をリセット + すべての Kstat の構成を消去しますか?この操作は元に戻せません。 + Kstat の構成の説明 + • add_sus_kstat_statically: ファイル、ディレクトリの静的な状態情報 + • add_sus_kstat: バインドマウント前にパスを追加して元の状態情報を保存します + • update_sus_kstat: ターゲットとなる ino を更新、サイズとブロックは変更しません + • update_sus_kstat_full_clone: ino のみ更新、他の値はそのままにします + Kstat の静的構成 + Kstat パスの管理 + Kstat の構成が未設定です。上のボタンをタップで追加します。 + + SUS マウントの非表示制御 + プロセスの SUS マウントを非表示する動作を制御します。 + すべてのプロセスで SUS マウントを非表示 + 有効化すると SUS マウントは KSU プロセスを含むすべてのプロセスから非表示になります。 + 無効化すると SUS マウントは非 KSU プロセスからのみ非表示になり、KSU プロセスはマウントを見ることができます。 + すべてのプロセスで SUS マウントの非表示を有効化しました + すべてのプロセスで SUS マウントの非表示を無効化しました + 画面のロック解除後または service.sh または boot-completed.sh の段階で無効に設定することを推奨します。これにより、KSU プロセスによってマウントされたマウントに依存する一部の root 化されたアプリの問題が解決されるはずです。 + 現在の設定: %s + すべてのプロセスを非表示 + 非 KSU プロセスのみ非表示 + 簡潔モードなカーネル バージョン + SukiSU のカーネル バージョンによって表示されるクリーンモードを有効または無効します。 + Android データパスが設定されました: %s + SD カードのパスは次のように設定済みです: %s + パスの設定は完全に成功しない可能性がありますが、SUS パスは引き続き追加されます。 + + バックアップ + SuSFS のすべての設定のバックアップを作成します。バックアップファイルは「すべての設定、パス、構成」が含まれます。 + バックアップを作成 + バックアップの作成に成功しました: %s + バックアップの作成に失敗しました: %s + バックアップファイルが見つかりません + 無効なバックアップファイル形式 + バックアップのバージョンが一致しませんが、復元を試みます。 + 復元 + SuSFS の構成をバックアップファイルから復元します。これにより、現在の設定がすべて上書きされます。 + バックアップファイルを選択 + デバイス: %s から「%s」に作成されたバックアップから構成が正常に復元されました。 + 復元に失敗しました: %s + 復元を確認 + これにより現在の SuSFS 構成がすべて上書きされます。続行してもよろしいですか? + 復元 + バックアップ日時: %s + デバイス: %s + バージョン: %s + ロック状態 + late_start サービスモードでブートローダーのロック状態属性を上書きする + 残骸をクリーンアップ + 様々なモジュールや残骸となったツールのファイルとディレクトリをクリーンアップします (誤って削除すると損失や起動の失敗に繋がる可能性があるため、注意して使用してください) + SUS のパスを編集 + SUS マウントを編集 + アンマウントを試すを編集 + Kstat 静的構成を編集 + Kstat のパスを編集 + 保存 + 編集 + 消去 + 更新 + Kstat の構成を更新 + Kstat のパスを更新 + フルクローンの SuSFS を更新 + Zygote 分離サービスをアンマウント + このオプションを有効化すると、システムの起動時に Zygote 分離サービスのマウントポイントがアンマウントされます。 + Zygote 分離サービスのアンマウントが有効です + Zygote 分離サービスのアンマウントが無効です + アプリのパス + その他のパス + その他 + アプリ + 追加のアプリパス + アプリを検索 + %1$d 個のアプリを選択済み + %1$d 個のアプリを追加済み + すべてのアプリが追加されました + 動的な署名の構成 + 有効 (サイズ: %s) + 無効 + 動的な署名を有効化 + 署名のサイズ + 署名のハッシュ + ハッシュは 64 桁の 16 進数の文字列でなければなりません。 + 動的な署名の構成が正常に設定されました + 動的な署名の構成の設定に失敗しました + 無効な署名の構成 + 動的な署名が無効です + 動的な署名の消去に失敗しました + 動的 + 署名 %1$d + 不明 + 有効なマネージャー + 有効なマネージャーがありません + SukiSU + Zygisk を実装 + + SUS ループパス + SUS ループパスを追加 + SUS ループパスを編集 + SUS ループパスが正常に追加されました: %1$s + SUS ループパスが削除されました: %1$s + SUS ループパスが更新されました: %1$s -> %2$s + SUS ループパスが構成されていません + ループパスをリセット + すべての SUS ループパスを消去してもよろしいですか?この操作は元に戻せません。 + ループパス + /data/example/path + 注意: ループパス経由で追加できるのは「/storage/」と「/sdcard/」内にないパスのみです。 + エラー: ループパスは「/storage/」または「/sdcard/」のディレクトリ内に配置できません。 + ループパス + ループパスを追加 + + ループパスの構成 + ループパスは、非 root ユーザーアプリまたは独立したサービスの起動ごとに SUS_PATH として再設定されます。これにより、追加されたパスの inode ステータスがリセットされたり、カーネル内で inode が再生成される問題に対処できます。 + AVC ログの偽装 + AVC ログの偽装が有効化されました + AVC ログの偽装が無効化されました + 無効: カーネルの AVC ログに表示される「su」の SUS T コンテキストの偽装を無効化します。\n +有効: カーネルの AVC ログに表示される「kernel」を使用して「su」の SUS T コンテキストを偽装する機能を有効化します。 + 重要な注意事項:\n +- カーネルはデフォルトで「0」に設定されています。\n +- これを有効化すると、開発者が何らかの権限や SELinux の問題をデバッグするときに原因を特定するのが難しくなる場合があるため、デバッグ時はこれを無効化することをお勧めします。 + + 検証済み + モジュールの署名が検証されました + 署名の検証 + モジュールのインストール時に署名の検証を強制します。(ARM アーキテクチャのみ) + 不明な発行元 + 署名されていないモジュールは不完全な可能性があります。デバイスを保護するため、このモジュールのインストールをブロックしました。 + 署名されていないモジュールは不完全な可能性があります。不明な発行元のモジュールをこのデバイスにインストールすることを許可しますか? + フックタイプ +
diff --git a/manager/app/src/main/res/values-kn/strings.xml b/manager/app/src/main/res/values-kn/strings.xml new file mode 100644 index 0000000..e60127c --- /dev/null +++ b/manager/app/src/main/res/values-kn/strings.xml @@ -0,0 +1,362 @@ + + + ಮನೆ + Not installed + Click to install + ಕೆಲಸ ಮಾಡುತ್ತಿದೆ + ವರ್ಷನ್: %s + ಬೆಂಬಲಿತವಾಗಿಲ್ಲ + KernelSU ಈಗ GKI ಕರ್ನಲ್‌ಗಳನ್ನು ಮಾತ್ರ ಬೆಂಬಲಿಸುತ್ತದೆ + ಕರ್ನಲ್ + SuSFS Version + ಮ್ಯಾನೇಜರ್ ವರ್ಷನ್ + SELinux ಸ್ಥಿತಿ + Disabled + Enforcing + Permissive + ತಿಳಿಯದ + ಸೂಪರ್ಯೂಸರ್ + ಮಾಡ್ಯೂಲ್ ಅನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಲು ವಿಫಲವಾಗಿದೆ: %s + ಮಾಡ್ಯೂಲ್ ಅನ್ನು ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಲು ವಿಫಲವಾಗಿದೆ: %s + ಮಾಡ್ಯೂಲ್ ಅನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿಲ್ಲ + ಮಾಡ್ಯೂಲ್ + Sort (Action first) + Sort (Enabled first) + ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ + Install + Install + ರೀಬೂಟ್ + Settings + ಸಾಫ್ಟ್ ರೀಬೂಟ್ + Reboot to Recovery + Reboot to Bootloader + Reboot to Download + EDL ಗೆ ರೀಬೂಟ್ + ಬಗ್ಗೆ + %s ಮಾಡ್ಯೂಲ್ ಅನ್ನು ಅಸ್ಥಾಪಿಸಲು ನೀವು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ\? + %s ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಲಾಗಿದೆ + ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ: %s + ವರ್ಷನ್ + ಲೇಖಕ + ರಿಫ್ರೆಶ್ + ಸಿಸ್ಟಮ್ ಅಪ್ಲಿಕೇಶನ್‌ಗಳನ್ನು ತೋರಿಸಿ + ಸಿಸ್ಟಮ್ ಅಪ್ಲಿಕೇಶನ್‌ಗಳನ್ನು ಮರೆಮಾಡಿ + ಲಾಗ್ ಕಳುಹಿಸಿ + ಸುರಕ್ಷಿತ ಮೋಡ್ + ಪರಿಣಾಮ ಬೀರಲು ರೀಬೂಟ್ ಮಾಡಿ + ಮಾಡ್ಯೂಲ್‌ಗಳನ್ನು ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಲಾಗಿದೆ ಏಕೆಂದರೆ ಇದು ಮ್ಯಾಜಿಸ್ಕ್‌ನೊಂದಿಗೆ ಸಂಘರ್ಷವಾಗಿದೆ! + KernelSU ಕಲಿಯಿರಿ + https://kernelsu.org/guide/what-is-kernelsu.html + KernelSU ಅನ್ನು ಹೇಗೆ ಸ್ಥಾಪಿಸಬೇಕು ಮತ್ತು ಮಾಡ್ಯೂಲ್‌ಗಳನ್ನು ಬಳಸುವುದು ಹೇಗೆ ಎಂದು ತಿಳಿಯಿರಿ + ನಮ್ಮನ್ನು ಬೆಂಬಲಿಸಿ + KernelSU ಉಚಿತ ಮತ್ತು ಮುಕ್ತ ಮೂಲವಾಗಿದೆ ಮತ್ತು ಯಾವಾಗಲೂ ಇರುತ್ತದೆ. ಆದಾಗ್ಯೂ ನೀವು ದೇಣಿಗೆ ನೀಡುವ ಮೂಲಕ ನೀವು ಕಾಳಜಿ ವಹಿಸುತ್ತೀರಿ ಎಂದು ನಮಗೆ ತೋರಿಸಬಹುದು. + Join our %2$s channel]]> + ಡೀಫಾಲ್ಟ್ + ಟೆಂಪ್ಲೇಟ್ + ಕಸ್ಟಮ್ + ಪ್ರೊಫೈಲ್ ಹೆಸರು + ಗುಂಪುಗಳು + ಸಾಮರ್ಥ್ಯಗಳು + SELinux ಸಂದರ್ಭ + Umount ಮಾಡ್ಯೂಲ್‌ಗಳು + %s ಗಾಗಿ ಅಪ್ಲಿಕೇಶನ್ ಪ್ರೊಫೈಲ್ ಅನ್ನು ನವೀಕರಿಸಲು ವಿಫಲವಾಗಿದೆ + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + ಡೀಫಾಲ್ಟ್ ಆಗಿ Umount ಮಾಡ್ಯೂಲ್ + ಅಪ್ಲಿಕೇಶನ್ ಪ್ರೊಫೈಲ್‌ಗಳಲ್ಲಿ \"Umount ಮಾಡ್ಯೂಲ್\" ಗಾಗಿ ಜಾಗತಿಕ ಡೀಫಾಲ್ಟ್ ಮೌಲ್ಯ. ಸಕ್ರಿಯಗೊಳಿಸಿದರೆ, ಪ್ರೊಫೈಲ್ ಸೆಟ್ ಅನ್ನು ಹೊಂದಿರದ ಅಪ್ಲಿಕೇಶನ್‌ಗಳಿಗಾಗಿ ಸಿಸ್ಟಮ್‌ಗೆ ಎಲ್ಲಾ ಮಾಡ್ಯೂಲ್ ಮಾರ್ಪಾಡುಗಳನ್ನು ಇದು ತೆಗೆದುಹಾಕುತ್ತದೆ. + ಈ ಆಯ್ಕೆಯನ್ನು ಸಕ್ರಿಯಗೊಳಿಸುವುದರಿಂದ ಈ ಅಪ್ಲಿಕೇಶನ್‌ಗಾಗಿ ಮಾಡ್ಯೂಲ್‌ಗಳ ಮೂಲಕ ಯಾವುದೇ ಮಾರ್ಪಡಿಸಿದ ಫೈಲ್‌ಗಳನ್ನು ಮರುಸ್ಥಾಪಿಸಲು KernelSU ಗೆ ಅನುಮತಿಸುತ್ತದೆ. + ಡೊಮೇನ್ + ನಿಯಮಗಳು + Update + ಮಾಡ್ಯೂಲ್ ಅನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗುತ್ತಿದೆ: %s + ಡೌನ್‌ಲೋಡ್ ಮಾಡುವುದನ್ನು ಪ್ರಾರಂಭಿಸಿ: %s + ಹೊಸ ಆವೃತ್ತಿ: %s ಲಭ್ಯವಿದೆ, ಅಪ್‌ಗ್ರೇಡ್ ಮಾಡಲು ಕ್ಲಿಕ್ ಮಾಡಿ + ಲಾಂಚ್ + ಫೋರ್ಸ್ ಸ್ಟಾಪ್ + Restart + Failed to update SELinux rules for %s + ಚೇಂಜ್ಲಾಗ್ + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + ಲಾಗ್ಗಳನ್ನು ಉಳಿಸಿ + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-ko/strings.xml b/manager/app/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..8e6ee7d --- /dev/null +++ b/manager/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,362 @@ + + + + 설치되지 않음 + 이 곳을 눌러 설치하기 + 정상 작동 중 + 버전: %s + 지원되지 않음 + KernelSU는 현재 GKI 커널만 지원합니다 + 커널 + SuSFS Version + 매니저 버전 + SELinux 상태 + 비활성화됨 + 적용 + 허용 + 알 수 없음 + 슈퍼유저 + 모듈 활성화 실패: %s + 모듈 비활성화 실패: %s + 설치된 모듈 없음 + 모듈 + 정렬 (동작이 있는 것 우선) + 정렬 (활성화됨 우선) + 삭제 + 설치 + 설치 + 다시 시작 + 설정 + 빠른 다시 시작 + 복구 모드로 다시 시작 + 부트로더로 다시 시작 + 다운로드 모드로 다시 시작 + EDL 모드로 다시 시작 + 정보 + %s 모듈을 삭제할까요? + %s 모듈 삭제됨 + 모듈 삭제 실패: %s + 버전 + 제작자 + 새로고침 + 시스템 앱 보이기 + 시스템 앱 숨기기 + 로그 보내기 + 안전 모드 + 다시 시작하여 변경 사항 적용 + Magisk와 충돌로 모듈을 사용할 수 없습니다! + KernelSU 알아보기 + https://kernelsu.org/guide/what-is-kernelsu.html + KernelSU 설치 방법과 모듈 사용 방법을 확인합니다 + 지원이 필요합니다 + KernelSU는 지금도, 앞으로도 항상 무료이며 오픈 소스로 유지됩니다. 기부를 통해 여러분의 관심을 보여주세요. + Join our %2$s channel]]> + 기본값 + 템플릿 + 사용자 지정 + 프로필 이름 + 사용자 그룹 + 권한 + SELinux 컨텍스트 + 모듈 사용 해제 + %s에 대한 앱 프로필 업데이트 실패 + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + 기본값으로 모듈 사용 해제 + 앱 프로필 메뉴의 \"모듈 마운트 해제\" 설정에 대한 전역 기본값을 설정합니다. 활성화 시, 개별 프로필이 설정되지 않은 앱은 시스템에 대한 모듈의 모든 수정사항이 적용되지 않습니다. + 이 옵션이 활성화되면, KernelSU는 이 앱에 대한 모듈의 모든 수정사항을 복구합니다. + 도메인 + 규칙 + 업데이트 + 모듈 받는 중: %s + 다운로드 시작: %s + 새 버전: %s이 사용 가능합니다, 여기를 눌러 업그레이드하세요. + 실행 + 강제 중지 + 다시 시작 + 다음 앱에 대한 SELinux 규칙 업데이트 실패: %s + 업데이트 내역 + 앱 프로필 템플레이트 + 앱 프로필의 로컬 및 온라인 템플레이트 관리 + 템플레이트 생성 + 템플레이트 편집 + ID + 올바르지 않은 템플레이트 id + 이름 + 설명 + 저장 + 삭제 + 템플레이트 보기 + 읽기 전용 + 템플레이트 ID가 이미 존재합니다! + 불러오기/내보내기 + 클립보드에서 불러오기 + 클립보드로 내보내기 + 내보낼 로컬 템플레이트가 없습니다! + 불러오기 성공 + 온라인 템플레이트 동기화 + 템플레이트 저장 실패 + 클립보드가 비었습니다! + 업데이트 내역 가져오기 실패: %s + 업데이트 확인 + 앱 실행시 자동으로 업데이트 확인 + 루트 부여 실패! + 동작 + Close + WebView 디버깅 활성화 + WebUI 디버깅에 사용 가능, 필요할 때만 활성화해주세요. + 직접 설치 (권장) + 파일 선택 + 비활성 슬롯에 설치 (OTA 이후) + 재부팅 후 기기는 **강제로** 비활성 슬롯으로 부팅합니다!\nOTA를 진행한 후에만 이 옵션을 사용하세요.\n진행할까요? + 다음 + %1$s 파티션 이미지 권장됨 + KMI 선택 + 삭제 + 임시적 삭제 + 영구적 삭제 + 순정 이미지 복구 + 임시적으로 KernelSU를 삭제하고, 다음 재부팅에 원래대로 복구합니다. + 완전히, 그리고 영구히 KernelSU (루트 및 모든 모듈)를 삭제합니다. + 순정 이미지 복구 (백업이 존재한다면), OTA 전에 사용합니다; KernelSU를 삭제해야 한다면, \"영구적 삭제\"를 사용해 주세요. + 플래시 중 + 플래시 성공 + 플래시 실패 + 선택된 LKM: %s + 로그 저장 + 로그 저장됨 + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-lt/strings.xml b/manager/app/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..4206c33 --- /dev/null +++ b/manager/app/src/main/res/values-lt/strings.xml @@ -0,0 +1,362 @@ + + + Namai + Neįdiegta + Spustelėkite norėdami įdiegti + Veikia + Versija: %s + Nepalaikoma + KernelSU dabar palaiko tik GKI branduolius + Branduolys + SuSFS Version + Tvarkyklės versija + SELinux statusas + Išjungta + Priverstinas + Leistinas + Nežinomas + Supernaudotojai + Nepavyko įjungti modulio: %s + Nepavyko išjungti modulio: %s + Nėra įdiegtų modulių + Moduliai + Sort (Action first) + Sort (Enabled first) + Išdiegti + Įdiegti + Įdiegti + Paleisti iš naujo + Parametrai + Perkrovimas neišjungus + Perkrauti į atkūrimo rėžimą + Perkrauti į įkrovos tvarkyklę + Perkrauti į atsisiuntimo rėžimą + Perkrauti į EDL + Apie + Ar tikrai norite išdiegti modulį %s\? + %s išdiegtas + Nepavyko išdiegti: %s + Versija + Autorius + Atšviežinti + Rodyti sistemos programas + Slėpti sistemos programas + Siųsti žurnalą + Saugus rėžimas + Paleiskite iš naujo, kad įsigaliotų + Moduliai yra išjungti, nes jie konfliktuoja su Magisk\'s! + Sužinokite apie KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Sužinokite, kaip įdiegti KernelSU ir naudoti modulius + Paremkite mus + KernelSU yra ir visada bus nemokamas ir atvirojo kodo. Tačiau galite parodyti, kad jums rūpi, paaukodami mums. + Join our %2$s channel]]> + Numatytas + Šablonas + Pasirinktinis + Profilio pavadinimas + Grupės + Galimybės + SELinux kontekstas + Atjungti modulius + Nepavyko atnaujinti programos profilio %s + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Atjungti modulius pagal numatytuosius parametrus + Visuotinė numatytoji „Modulių atjungimo“ reikšmė programų profiliuose. Jei įjungta, ji pašalins visus sistemos modulio pakeitimus programoms, kurios neturi profilio. + Įjungus šią parinktį, KernelSU galės atkurti visus modulių modifikuotus failus šiai programai. + Domenas + Taisyklės + Atnaujinti + Atsisiunčiamas modulis: %s + Pradedamas atsisiuntimas: %s + Nauja versija: %s pasiekiama, spustelėkite norėdami atsinaujinti + Paleisti + Priversti sustoti + Perkrauti + Nepavyko atnaujinti SELinux taisyklių: %s + Keitimų žurnalas + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + Saglabāt Žurnālus + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-lv/strings.xml b/manager/app/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..92b8287 --- /dev/null +++ b/manager/app/src/main/res/values-lv/strings.xml @@ -0,0 +1,364 @@ + + + Sākums + Nav ieinstalēts + Noklikšķiniet, lai instalētu + Darbojas + Versija: %s + Neatbalstīts + KernelSU atbalsta tikai GKI kodolus + Kodols + SuSFS Version + Pārvaldnieka versija + SELinux statuss + Atspējots + Izpildīšana + Visatļautība + Nezināms + SuperLietotājs + Neizdevās iespējot moduli: %s + Neizdevās atspējot moduli: %s + Nav instalētu moduļu + Moduļi + Sort (Action first) + Sort (Enabled first) + Atinstalēt + Instalēt + Instalēt + Restartēt + Iestatījumi + Ātri restartēt + Restartēt uz Recovery + Restartēt uz Bootloaderu + Restartēt uz Download + Restartēt uz EDL + Par + Vai tiešām vēlaties atinstalēt moduli %s? + %s ir atinstalēts + Neizdevās atinstalēt: %s + Versija + Autors + Atjaunot + Rādīt sistēmas lietotnes + Slēpt sistēmas lietotnes + Ziņot žurnālu + Drošais režīms + Restartējiet, lai stātos spēkā + Moduļi ir atspējoti, jo tie konfliktē ar Magisk! + Uzzināt par KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Uzzināt, kā instalēt KernelSU un izmantot moduļus + Atbalsti mūs + KernelSU ir un vienmēr būs bezmaksas un atvērtā koda. Tomēr jūs varat parādīt mums, ka jums rūp, veicot ziedojumu. + Join our %2$s channel]]> + Noklusējums + Veidne + Pielāgots + Profila vārds + Grupas + Iespējas + SELinux konteksts + Atvienot moduļus + Neizdevās atjaunināt lietotnes profilu %s + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Pēc noklusējuma atvienot moduļus + Globālā noklusējuma vērtība vienumam “Atvienot moduļus” lietotņu profilos. Ja tas ir iespējots, lietojumprogrammām, kurām nav iestatīts profils, tiks noņemtas visas sistēmas moduļu modifikācijas. + Iespējojot šo opciju, KernelSU varēs atjaunot visus moduļos šīs lietojumprogrammas modificētos failus. + Domēns + Noteikumi + Atjaunināt + Lejupielādē moduli: %s + Sākt lejupielādi: %s + Jaunā versija: %s ir pieejama, noklikšķiniet, lai atjauninātu + Palaist + Piespiedu apstāšana + Restartēt aplikāciju + Neizdevās atjaunināt SELinux noteikumus: %s + Izmaiņu žurnāls + Lietotnes profila veidne + Pārvaldiet vietējo un tiešsaistes lietotņu profila veidni + Izveidot veidni + Rediģēt veidni + id + Nederīgs veidnes id + Vārds + Apraksts + Saglabāt + Dzēst + Skatīt veidni + tikai lasīt + veidnes id jau pastāv! + Importēt/Eksportēt + Importēt no starpliktuves + Eksportēt starpliktuvē + Nevar atrast vietējo eksportējamo veidni! + Importēts veiksmīgi + Sinhronizēt tiešsaistes veidnes + Neizdevās saglabāt veidni + Starpliktuve ir tukša! + Izmaiņu žurnāla iegūšana neizdevās: %s + Pārbaudīt atjauninājumus + Automātiski pārbaudīt atjauninājumus atverot aplikāciju + Neizdevās piešķirt sakni! + Action + Close + Iespējot WebView atkļūdošanu + Var izmantot WebUI atkļūdošanai, lūdzu, izmantot tikai tad, kad tas ir nepieciešams. + Tiešā instalēšana (Ieteicams) + Izvēlieties failu + Instalēt neaktīvajā slotā (pēc OTA) + Pēc restartēšanas jūsu ierīce tiks **PIESPIESTI** palaista pašreizējā neaktīvajā slotā! +\nIzmantojiet šo opciju tikai pēc OTA pabeigšanas +\nTurpināt? + Nākamais + Ieteicams %1$s nodalījuma attēls + Izvēlieties KMI + Atinstalēt + Pagaidu atinstalēšana + Neatgriezeniski atinstalēt + Atjaunot oriģinālo attēlu + Īslaicīgi atinstalēt KernelSU, pēc nākamās restartēšanas atjaunot sākotnējo stāvokli. + KernelSU (saknes un visu moduļu) pilnīga atinstalēšana. + Atjaunojot rūpnīcas attēlu (ja ir dublējums), ko parasti izmanto pirms OTA; ja nepieciešams atinstalēt KernelSU, lūdzu, izmantojiet \"Neatgriezeniski atinstalēt\". + Instalē + Instalēts veiksmīgi + Instalēšana neizdevās + Izvēlētais lkm: %s + Išsaugoti Žurnalus + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-mr/strings.xml b/manager/app/src/main/res/values-mr/strings.xml new file mode 100644 index 0000000..d070c17 --- /dev/null +++ b/manager/app/src/main/res/values-mr/strings.xml @@ -0,0 +1,362 @@ + + + होम + इंस्टॉल केले नाही + इंस्टॉल साठी क्लिक करा + कार्यरत + आवृत्ती: %s + असमर्थित + KernelSU आता फक्त GKI कर्नलचे समर्थन करते + कर्नल + SuSFS Version + व्यवस्थापक आवृत्ती + SELinux स्थिती + अक्षम + एनफोर्सिंग + परमिसिव + अज्ञात + सुपरयुझर + मॉड्यूल सक्षम करण्यात अयशस्वी: %s + मॉड्यूल अक्षम करण्यात अयशस्वी: %s + कोणतेही मॉड्यूल स्थापित केलेले नाही + मॉड्यूल + Sort (Action first) + Sort (Enabled first) + विस्थापित करा + स्थापित करा + स्थापित करा + रीबूट करा + सेटिंग्ज + सॉफ्ट रीबूट + रिकवरी मध्ये रिबुट करा + बूटलोडरवर रीबूट करा + डाउनलोड करण्यासाठी रीबूट करा + EDL वर रीबूट करा + बद्दल + तुमची खात्री आहे की तुम्ही मॉड्यूल %s विस्थापित करू इच्छिता\? + %s विस्थापित + विस्थापित करण्यात अयशस्वी: %s + आवृत्ती + लेखक + रिफ्रेश करा + सिस्टम अॅप्स दाखवा + सिस्टम अॅप्स लपवा + लॉग पाठवा + सुरक्षित मोड + प्रभावी होण्यासाठी रीबूट करा + मॉड्यूल अक्षम केले आहेत कारण ते Magisk च्या विरोधाभास आहे! + KernelSU शिका + https://kernelsu.org/guide/what-is-kernelsu.html + KernelSU कसे स्थापित करायचे आणि मॉड्यूल कसे वापरायचे ते शिका + आम्हाला पाठिंबा द्या + KernelSU विनामूल्य आणि मुक्त स्रोत आहे, आणि नेहमीच असेल. तथापि, देणगी देऊन तुम्ही आम्हाला दाखवू शकता की तुमची काळजी आहे. + Join our %2$s channel]]> + डीफॉल्ट + साचा + कस्टम + प्रोफाइल नाव + गट + क्षमता + SELinux संदर्भ + उमाउंट मॉड्यूल्स + %s साठी अॅप प्रोफाइल अपडेट करण्यात अयशस्वी + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + डीफॉल्टनुसार मॉड्यूल्स उमाउंट करा + अॅप प्रोफाइलमधील \"उमाउंट मॉड्यूल्स\" साठी जागतिक डीफॉल्ट मूल्य. सक्षम असल्यास, ते प्रोफाइल सेट नसलेल्या ॲप्लिकेशनचे सिस्टममधील सर्व मॉड्यूल बदल काढून टाकेल. + हा पर्याय सक्षम केल्याने KernelSU ला या ऍप्लिकेशनसाठी मॉड्यूल्सद्वारे कोणत्याही सुधारित फाइल्स पुनर्संचयित करण्यास अनुमती मिळेल. + डोमेन + नियम + अपडेट करा + मॉड्यूल डाउनलोड करत आहे: %s + डाउनलोड करणे सुरू करा: %s + नवीन आवृत्ती: %s उपलब्ध आहे, डाउनलोड करण्यासाठी क्लिक करा + लाँच करा + सक्तीने थांबा + पुन्हा सुरू करा + यासाठी SELinux नियम अपडेट करण्यात अयशस्वी: %s + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + लॉग जतन करा + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-ms/strings.xml b/manager/app/src/main/res/values-ms/strings.xml new file mode 100644 index 0000000..245e8b5 --- /dev/null +++ b/manager/app/src/main/res/values-ms/strings.xml @@ -0,0 +1,362 @@ + + + Layar Utama + Tidak terpasang + Tekan untuk memasang + Berjalan + Versi: %s + Tidak Disokong + KernelSU ketika ini hanya menyokong kernel GKI + Kernel + SuSFS Version + Versi manager + Status SELinux + Lumpuhkan + Enforcing + Permisif + Tidak Diketahui + Superuser + Modul tidak berjaya diaktifkan: %s + Gagal mematikan modul: %s + Tiada modul dipasang + Modul + Sort (Action first) + Sort (Enabled first) + Padam + Pasang + Pasang + Reboot + Tetapan + Soft Reboot + Reboot ke Recovery + Reboot ke Bootloader + Reboot ke Download + Reboot ke EDL + Tentang + Apakah anda pasti ingin membuang modul %s\? + %s uninstalled + Failed to uninstall: %s + Version + Author + Refresh + Show system apps + Hide system apps + Send logs + Safe mode + Reboot to take effect + Modules are unavailable due to a conflict with Magisk! + Learn KernelSU + https://kernelsu.org/guide/what-is-kernelsu.html + Learn how to install KernelSU and use modules + Support Us + KernelSU is, and always will be, free, and open source. You can however show us that you care by making a donation. + Join our %2$s channel]]> + Default + Template + Custom + Profile name + Groups + Capabilities + SELinux context + Umount modules + Failed to update App Profile for %s + The current KernelSU version %s is too low for the manager to work properly. Please upgrade to version %s or higher! + Umount modules by default + The global default value for \"Umount modules\" in App Profile. If enabled, it will remove all module modifications to the system for apps that don\'t have a profile set. + Enabling this option will allow KernelSU to restore any modified files by the modules for this app. + Domain + Rules + Update + Downloading module: %s + Start downloading: %s + New version %s is available, click to upgrade. + Launch + Force stop + Restart + Failed to update SELinux rules for %s + Changelog + App Profile Template + Manage local and online template of App Profile + Create template + Edit template + ID + Invalid template ID + Name + Description + Save + Delete + View template + Read only + Template ID already exists! + Import/Export + Import from clipboard + Export to clipboard + Cannot find local template to export! + Imported successfully + Sync online templates + Failed to save template + Clipboard is empty! + Fetch changelog failed: %s + Check update + Automatically check for updates when opening the app + Failed to grant root! + Action + Close + Enable WebView debugging + Can be used to debug WebUI. Please enable only when needed. + Direct install (Recommended) + Select a image that needs to be patched + Install to inactive slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? + Next + %1$s partition image is recommended + Select KMI + Uninstall + Uninstall temporarily + Uninstall permanently + Restore stock image + Temporarily uninstall KernelSU, restore to original state after next reboot. + Uninstalling KernelSU (Root and all modules) completely and permanently. + Restore the stock factory image (If a backup exists), usually used before OTA; if you need to uninstall KernelSU, please use \"Uninstall permanently\". + Flashing + Flash success + Flash failed + Selected LKM: %s + Simpan Log + Logs saved + + confirm install module %1$s? + unknown module + + Confirm Module Restoration + This operation will overwrite all existing modules. Continue? + Confirm + Cancel + + Backup successful (tar.gz) + Backup failed: %1$s + backup modules + restore modules + + Modules restored successfully, restart required + Restore failed: %1$s + Restart Now + Unknown error + + Command execution failed: %1$s + + Allowlist backup successful + Allowlist backup failed: %1$s + Confirm Allowlist Restoration + This operation will overwrite the current allowlist. Continue? + Allowlist restored successfully + Allowlist restore failed: %1$s + Backup Allowlist + Restore Allowlist + Custom App Background + Select an image as background + Navigation bar transparency + Android version + Device model + Granting superuser to %s is not allowed + Disable su compatibility + Temporarily disable any applications from obtaining root privileges via the ⁠su command (existing root processes will not be affected). + Sure you want to install the following %1$d modules? \n\n%2$s + More settings + SELinux + Enabled + Disabled + Simplicity mode + Hides unnecessary cards when turned on + Hide kernel version + Hide kernel version + Hide other info + Hides information about the number of super users, modules and KPM modules on the home page + Hide SuSFS status + Hide SuSFS status information on the home page + Hide Link Card Status + Hide link card information on the home page + Theme + Follow system + Light + Dark + Manual Hook + Dynamic colours + Dynamic colours using system themes + Choose a theme colour + Blue + Green + Purple + Orange + Pink + Gray + Yellow + Install Anykernel3 + Flash AnyKernel3 kernel file + Requires root privileges + Scrubbing complete + Whether to reboot immediately? + Yes + No + Reboot Failed + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Unable to check if module file exists + Theme Color + Incorrect file type! Please select .kpm file. + Uninstall + The following KPM will be uninstalled: %s + Use two fingers to zoom the image, and one finger to drag it to adjust the position + Reprovision + + Flash Complete + + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current Slot:%1$s + + Copy failed + Unknown error + Flash failed + + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + + Application not found + SELinux Enabled + SELinux Disabled + SELinux Status change failed + Advanced Settings + Customize the toolbar + Comeback + Background set successfully + Removed custom backgrounds + Alternate icon + Change the launcher icon to KernelSU\'s icon. + Icon switched + + Display KPM Function + Display KPM information and Function in home and bottom bar (Need to reopen the app) + + Select the WebUI engine to use + Automatic Selection + Force the use of WebUI X + Mandatory use of KSU WebUI + Inject Eruda into WebUI X + Inject a debug console into WebUI X to make debugging easier. Requires web debugging to be on. + + Applied DPI + Adjust the screen display density for the current application only + Small + Medium + Big + oversize + customizable + Applying DPI settings + Confirm DPI change + Are you sure you want to change the application DPI from %1$d to %2$d? + Application needs to be restarted to apply the new DPI settings, does not affect the system status bar or other applications + DPI has been set to %1$d, effective after restarting the application + + App Language + Follow System + Card Darkness Adjustment + + error code + Please check the log + Module being installed %1$d/%2$d + %d Failed to install a new module + Module download failed + Kernel Flashing + + All + Root + Custom + Default + + Ascending order of name + Name descending + Installation time (new) + Installation time (old) + descending order of size + ascending order of size + frequency of use + + No application in this category + + + Delegation of authority + Authorizations + Unmounting Module Mounts + Disable uninstall module mounting + Expand menu + Put away the menu + Top + Bottom + Selected + option + + Menu Options + Sort by + Application Type Selection + + + + + + + + + + + + + + + + + diff --git a/manager/app/src/main/res/values-night/themes.xml b/manager/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..d76ba8e --- /dev/null +++ b/manager/app/src/main/res/values-night/themes.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/xml/backup_rules.xml b/manager/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/manager/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/xml/data_extraction_rules.xml b/manager/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/manager/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/xml/filepaths.xml b/manager/app/src/main/res/xml/filepaths.xml new file mode 100644 index 0000000..f8a9a5c --- /dev/null +++ b/manager/app/src/main/res/xml/filepaths.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/manager/app/src/main/res/xml/network_security_config.xml b/manager/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6dd26cc --- /dev/null +++ b/manager/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 127.0.0.1 + 0.0.0.0 + ::1 + + diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts new file mode 100644 index 0000000..396caec --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,82 @@ +import com.android.build.api.dsl.ApplicationDefaultConfig +import com.android.build.api.dsl.CommonExtension +import com.android.build.gradle.api.AndroidBasePlugin + +plugins { + alias(libs.plugins.agp.app) apply false + alias(libs.plugins.agp.lib) apply false + alias(libs.plugins.kotlin) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.lsplugin.cmaker) +} + +cmaker { + default { + arguments.addAll( + arrayOf( + "-DANDROID_STL=none", + ) + ) + abiFilters("arm64-v8a", "x86_64", "armeabi-v7a") + } + buildTypes { + if (it.name == "release") { + arguments += "-DDEBUG_SYMBOLS_PATH=${layout.buildDirectory.asFile.get().absolutePath}/symbols" + } + } +} + +val androidMinSdkVersion = 26 +val androidTargetSdkVersion = 36 +val androidCompileSdkVersion = 36 +val androidBuildToolsVersion = "36.1.0" +val androidCompileNdkVersion by extra(libs.versions.ndk.get()) +val androidCmakeVersion by extra("3.22.0+") +val androidSourceCompatibility = JavaVersion.VERSION_21 +val androidTargetCompatibility = JavaVersion.VERSION_21 +val managerVersionCode by extra(4 * 10000 + getGitCommitCount() - 2815) +val managerVersionName by extra(getGitDescribe()) + +fun getGitCommitCount(): Int { + return providers.exec { + commandLine("git", "rev-list", "--count", "HEAD") + }.standardOutput.asText.get().trim().toInt() +} + +fun getGitDescribe(): String { + return providers.exec { + commandLine("git", "describe", "--tags", "--always", "--abbrev=0") + }.standardOutput.asText.get().trim() +} + +subprojects { + plugins.withType(AndroidBasePlugin::class.java) { + extensions.configure(CommonExtension::class.java) { + compileSdk = androidCompileSdkVersion + ndkVersion = androidCompileNdkVersion + buildToolsVersion = androidBuildToolsVersion + + defaultConfig { + minSdk = androidMinSdkVersion + if (this is ApplicationDefaultConfig) { + targetSdk = androidTargetSdkVersion + versionCode = managerVersionCode + versionName = managerVersionName + } + ndk { + abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a") + } + } + + lint { + abortOnError = true + checkReleaseBuilds = false + } + + compileOptions { + sourceCompatibility = androidSourceCompatibility + targetCompatibility = androidTargetCompatibility + } + } + } +} diff --git a/manager/gradle.properties b/manager/gradle.properties new file mode 100644 index 0000000..980cafa --- /dev/null +++ b/manager/gradle.properties @@ -0,0 +1,8 @@ +android.experimental.enableNewResourceShrinker.preciseShrinking=true +android.enableAppCompileTimeRClass=true +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048m +org.gradle.parallel=true +org.gradle.vfs.watch=true +android.r8.maxWorkers=4 +android.native.buildOutput=verbose diff --git a/manager/gradle/libs.versions.toml b/manager/gradle/libs.versions.toml new file mode 100644 index 0000000..d9e7dd2 --- /dev/null +++ b/manager/gradle/libs.versions.toml @@ -0,0 +1,94 @@ +[versions] +accompanist-drawablepainter = "0.37.3" +agp = "8.13.1" +gson = "2.13.2" +kotlin = "2.2.21" +ksp = "2.2.21-2.0.4" +compose-bom = "2025.11.00" +lifecycle = "2.9.4" +navigation = "2.9.6" +activity-compose = "1.11.0" +kotlinx-coroutines = "1.10.2" +coil-compose = "2.7.0" +compose-destination = "2.3.0" +sheets-compose-dialogs = "1.3.0" +markdown = "4.6.2" +webkit = "1.14.0" +appiconloader-coil = "1.5.0" +parcelablelist = "2.0.1" +libsu = "6.0.0" +apksign = "1.4" +cmaker = "1.2" +compose-material = "1.9.4" +compose-material3 = "1.4.0" +compose-ui = "1.9.4" +documentfile = "1.1.0" +mmrl = "2bb00b3c2b" +ndk = "29.0.13599879-beta2" +foundation = "1.9.4" + +[plugins] +agp-app = { id = "com.android.application", version.ref = "agp" } +agp-lib = { id = "com.android.library", version.ref = "agp" } + +kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } + +lsplugin-apksign = { id = "org.lsposed.lsplugin.apksign", version.ref = "apksign" } +lsplugin-cmaker = { id = "org.lsposed.lsplugin.cmaker", version.ref = "cmaker" } + +[libraries] +accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist-drawablepainter" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } + +androidx-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } + +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "compose-material" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "compose-material3" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose-ui" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose-ui" } + +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } + +com-github-topjohnwu-libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } +com-github-topjohnwu-libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" } +com-github-topjohnwu-libsu-io = { group = "com.github.topjohnwu.libsu", name = "io", version.ref = "libsu" } + +dev-rikka-rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version.ref = "parcelablelist" } + +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +io-coil-kt-coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil-compose" } + +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } + +me-zhanghai-android-appiconloader-coil = { group = "me.zhanghai.android.appiconloader", name = "appiconloader-coil", version.ref = "appiconloader-coil" } + +compose-destinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "compose-destination" } +compose-destinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "compose-destination" } + +sheet-compose-dialogs-core = { group = "com.maxkeppeler.sheets-compose-dialogs", name = "core", version.ref = "sheets-compose-dialogs" } +sheet-compose-dialogs-list = { group = "com.maxkeppeler.sheets-compose-dialogs", name = "list", version.ref = "sheets-compose-dialogs" } +sheet-compose-dialogs-input = { group = "com.maxkeppeler.sheets-compose-dialogs", name = "input", version.ref = "sheets-compose-dialogs" } + +markdown = { group = "io.noties.markwon", name = "core", version.ref = "markdown" } + +lsposed-cxx = { module = "org.lsposed.libcxx:libcxx", version.ref = "ndk" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } + + +mmrl-webui = { group = "com.github.MMRLApp.MMRL", name = "webui", version.ref = "mmrl" } +mmrl-platform = { group = "com.github.MMRLApp.MMRL", name = "platform", version.ref = "mmrl" } +mmrl-ui = { group = "com.github.MMRLApp.MMRL", name = "ui", version.ref = "mmrl" } +mmrl-hidden-api = { group = "com.github.MMRLApp.MMRL", name = "hidden-api", version.ref = "mmrl" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } \ No newline at end of file diff --git a/manager/gradle/wrapper/gradle-wrapper.jar b/manager/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/manager/gradle/wrapper/gradle-wrapper.jar differ diff --git a/manager/gradle/wrapper/gradle-wrapper.properties b/manager/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bad7c24 --- /dev/null +++ b/manager/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/manager/gradlew b/manager/gradlew new file mode 100755 index 0000000..c27469b --- /dev/null +++ b/manager/gradlew @@ -0,0 +1,247 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/manager/gradlew.bat b/manager/gradlew.bat new file mode 100644 index 0000000..8747509 --- /dev/null +++ b/manager/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/manager/randomizer b/manager/randomizer new file mode 100644 index 0000000..2cd4e4b --- /dev/null +++ b/manager/randomizer @@ -0,0 +1,31 @@ +#! /usr/bin/env bash + +# Generate 3 random lowercase words (6 letters each) +word1=$(tr -dc 'a-z' 0 + tmp = url.removesuffix("/").replace(github, "").split("/") + print(tmp) + assert len(tmp) == 2 + maintainer = tmp[0] + print(maintainer) + maintainer_link = "%s%s" % (github, maintainer) + print(maintainer_link) + kernel_name = tmp[1] + print(kernel_name) + kernel_link = "%s%s/%s" % (github, maintainer, kernel_name) + print(kernel_link) + with open(file_name, "r") as f: + data = json.loads(f.read()) + data.append( + { + "maintainer": maintainer, + "maintainer_link": maintainer_link, + "kernel_name": kernel_name, + "kernel_link": kernel_link, + "devices": device, + } + ) + os.remove(file_name) + with open(file_name, "w") as f: + f.write(json.dumps(data, indent=4)) + os.system("echo success=true >> $GITHUB_OUTPUT") + os.system("echo device=%s >> $GITHUB_OUTPUT" % device) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/allowlist.bt b/scripts/allowlist.bt new file mode 100644 index 0000000..d95bb5a --- /dev/null +++ b/scripts/allowlist.bt @@ -0,0 +1,89 @@ +// Define constants as per the provided structure. +#define KSU_MAX_PACKAGE_NAME 256 +#define KSU_MAX_GROUPS 32 +#define KSU_SELINUX_DOMAIN 64 + +// Define the root_profile structure with padding for 64-bit alignment. +struct root_profile { + uint32 uid; + uint32 gid; + + uint32 groups_count; + uint32 groups[KSU_MAX_GROUPS]; + char padding1[4]; // Padding for 64-bit alignment. + + struct { + uint64 effective; + uint64 permitted; + uint64 inheritable; + } capabilities; + + char selinux_domain[KSU_SELINUX_DOMAIN]; + + uint32 namespaces; + char padding2[4]; // Padding for 64-bit alignment. +}; + +// Define the non_root_profile structure with padding for 64-bit alignment. +struct non_root_profile { + byte umount_modules; + char padding[7]; // Padding to make the total size a multiple of 8. +}; + +// Define the rp_config structure with padding for 64-bit alignment. +struct rp_config_t { + byte use_default; + + char template_name[KSU_MAX_PACKAGE_NAME]; + char padding[7]; // Padding to make the total size a multiple of 8. + + struct root_profile profile; +}; + +// Define the nrp_config structure with padding for 64-bit alignment. +struct nrp_config_t { + byte use_default; + char padding1[7]; // Padding to make the total size a multiple of 8. + + struct non_root_profile profile; + char padding2[488]; // Padding to align the union +}; + +// Define the main app_profile structure +typedef struct { + uint32 version; + char key[KSU_MAX_PACKAGE_NAME]; + int32 current_uid; + int64 allow_su; + + // Based on allow_su, decide which profile to use + if (allow_su != 0) { + rp_config_t rp_config; + } else { + nrp_config_t nrp_config; + } + +} app_profile; + +// Define the file header with magic number and version +typedef struct { + uint32 magic; + uint32 version; +} file_header; + +// Main entry for parsing the file +file_header header; + +if (header.magic != 0x7f4b5355) { + Printf("Invalid file magic number.\n"); + return; +} + +FSeek(8); // Skip the header + + +// Continually read app_profile instances until end of file +while (!FEof()) { + app_profile profile; +} + diff --git a/scripts/bin2c.py b/scripts/bin2c.py new file mode 100644 index 0000000..5851313 --- /dev/null +++ b/scripts/bin2c.py @@ -0,0 +1,51 @@ +#!/usr/bin/python3 + +import argparse +import os +import re + +line_size = 80 + + +def bin2c(filename, varname='data'): + if not os.path.isfile(filename): + print('File "%s" is not found!' % filename) + return '' + if not re.match('[a-zA-Z_][a-zA-Z0-9_]*', varname): + print('Invalid variable name "%s"' % varname) + return + with open(filename, 'rb') as in_file: + data = in_file.read() + # limit the line length + byte_len = 6 # '0x00, ' + out = 'unsigned int %s_size = %d;\n' \ + 'const char %s[%d] = {\n' % (varname, len(data), varname, len(data)) + line = '' + for byte in data: + line += '0x%02x, ' % byte + if len(line) + 4 + byte_len >= line_size: + out += ' ' * 4 + line + '\n' + line = '' + # add the last line + if len(line) + 4 + byte_len < line_size: + out += ' ' * 4 + line + '\n' + # strip the last comma + out = out.rstrip(', \n') + '\n' + out += '};' + return out + + +def main(): + """ Main func """ + parser = argparse.ArgumentParser() + parser.add_argument( + 'filename', help='filename to convert to C array') + parser.add_argument( + 'varname', nargs='?', help='variable name', default='data') + args = parser.parse_args() + # print out the data + print(bin2c(args.filename, args.varname)) + + +if __name__ == '__main__': + main() diff --git a/scripts/ksubot.py b/scripts/ksubot.py new file mode 100644 index 0000000..1b25149 --- /dev/null +++ b/scripts/ksubot.py @@ -0,0 +1,111 @@ +import asyncio +import os +import sys +from telethon import TelegramClient +from telethon.sessions import StringSession + +API_ID = 611335 +API_HASH = "d524b414d21f4d37f08684c1df41ac9c" + + +BOT_TOKEN = os.environ.get("BOT_TOKEN") +CHAT_ID = os.environ.get("CHAT_ID") +MESSAGE_THREAD_ID = os.environ.get("MESSAGE_THREAD_ID") +COMMIT_URL = os.environ.get("COMMIT_URL") +COMMIT_MESSAGE = os.environ.get("COMMIT_MESSAGE") +RUN_URL = os.environ.get("RUN_URL") +TITLE = os.environ.get("TITLE") +VERSION = os.environ.get("VERSION") +BRANCH = os.environ.get("BRANCH") +MSG_TEMPLATE = """ +**{title}** +Branch: {branch} +#ci_{version} +``` +{commit_message} +``` +[Commit]({commit_url}) +[Workflow run]({run_url}) +""".strip() + + +def get_caption(): + msg = MSG_TEMPLATE.format( + title=TITLE, + branch=BRANCH, + version=VERSION, + commit_message=COMMIT_MESSAGE, + commit_url=COMMIT_URL, + run_url=RUN_URL, + ) + if len(msg) > 1024: + return COMMIT_URL + return msg + + +def check_environ(): + global CHAT_ID, MESSAGE_THREAD_ID + if BOT_TOKEN is None: + print("[-] Invalid BOT_TOKEN") + exit(1) + if CHAT_ID is None: + print("[-] Invalid CHAT_ID") + exit(1) + else: + try: + CHAT_ID = int(CHAT_ID) + except: + pass + if COMMIT_URL is None: + print("[-] Invalid COMMIT_URL") + exit(1) + if COMMIT_MESSAGE is None: + print("[-] Invalid COMMIT_MESSAGE") + exit(1) + if RUN_URL is None: + print("[-] Invalid RUN_URL") + exit(1) + if TITLE is None: + print("[-] Invalid TITLE") + exit(1) + if VERSION is None: + print("[-] Invalid VERSION") + exit(1) + if BRANCH is None: + print("[-] Invalid BRANCH") + exit(1) + if MESSAGE_THREAD_ID and MESSAGE_THREAD_ID != "": + try: + MESSAGE_THREAD_ID = int(MESSAGE_THREAD_ID) + except: + print("[-] Invalid MESSAGE_THREAD_ID") + exit(1) + else: + MESSAGE_THREAD_ID = None + + +async def main(): + print("[+] Uploading to telegram") + check_environ() + files = sys.argv[1:] + print("[+] Files:", files) + if len(files) <= 0: + print("[-] No files to upload") + exit(1) + print("[+] Logging in Telegram with bot") + async with await TelegramClient(StringSession(), API_ID, API_HASH).start(bot_token=BOT_TOKEN) as bot: + caption = [""] * len(files) + caption[-1] = get_caption() + print("[+] Caption: ") + print("---") + print(caption) + print("---") + print("[+] Sending") + await bot.send_file(entity=CHAT_ID, file=files, caption=caption, reply_to=MESSAGE_THREAD_ID, parse_mode="markdown") + print("[+] Done!") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as e: + print(f"[-] An error occurred: {e}") diff --git a/userspace/ksud/.gitignore b/userspace/ksud/.gitignore new file mode 100644 index 0000000..3c71873 --- /dev/null +++ b/userspace/ksud/.gitignore @@ -0,0 +1,2 @@ +/target +.cargo/ \ No newline at end of file diff --git a/userspace/ksud/Cargo.lock b/userspace/ksud/Cargo.lock new file mode 100644 index 0000000..aab55b7 --- /dev/null +++ b/userspace/ksud/Cargo.lock @@ -0,0 +1,1835 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" + +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "extattr" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b59f8a77817ff1b795adafc535941bdf664184f5f95e0b6d1d77dd6d12815dc" +dependencies = [ + "bitflags 1.3.2", + "errno 0.2.8", + "libc", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "include-flate" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", + "libflate", + "zstd", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050" +dependencies = [ + "include-flate-compress", + "libflate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.110", + "zstd", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc" +dependencies = [ + "libflate", + "zstd", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "java-properties" +version = "2.0.0" +source = "git+https://github.com/Kernel-SU/java-properties.git?branch=master#42a4aa941b70ded2dd3be9e9f892471023e70229" +dependencies = [ + "encoding_rs", + "lazy_static", + "regex-lite", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" +dependencies = [ + "crossbeam", + "rayon", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "ksud" +version = "0.1.0" +dependencies = [ + "android-properties", + "android_logger", + "anyhow", + "chrono", + "clap", + "const_format", + "derive-new", + "encoding_rs", + "env_logger", + "extattr", + "getopts", + "humansize", + "is_executable", + "java-properties", + "jwalk", + "libc", + "log", + "nom", + "notify", + "regex-lite", + "rust-embed", + "rustix 0.38.34", + "serde", + "serde_json", + "sha1", + "sha256", + "tempfile", + "which", + "zip 6.0.0", + "zip-extensions", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libflate" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "dary_heap", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" +dependencies = [ + "core2", + "hashbrown", + "rle-decode-fast", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" +dependencies = [ + "crc", + "sha2", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rust-embed" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +dependencies = [ + "include-flate", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.110", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustix" +version = "0.38.34" +source = "git+https://github.com/Kernel-SU/rustix.git?rev=4a53fbc7cb7a07cabe87125cc21dbc27db316259#4a53fbc7cb7a07cabe87125cc21dbc27db316259" +dependencies = [ + "bitflags 2.10.0", + "errno 0.3.14", + "itoa", + "libc", + "linux-raw-sys 0.4.15", + "once_cell", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno 0.3.14", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha256" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "pin-project-lite", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix 1.1.2", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "lzma-rs", + "memchr", + "xz2", + "zopfli", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "deflate64", + "flate2", + "indexmap", + "lzma-rust2", + "memchr", + "time", + "zopfli", +] + +[[package]] +name = "zip-extensions" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f105becb0a5da773e655775dd05fee454ca1475bcc980ec9d940a02f42cee40" +dependencies = [ + "zip 3.0.0", +] + +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/userspace/ksud/Cargo.toml b/userspace/ksud/Cargo.toml new file mode 100644 index 0000000..ec56275 --- /dev/null +++ b/userspace/ksud/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "ksud" +version = "0.1.0" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +notify = "8.2" +anyhow = "1" +clap = { version = "4", features = ["derive"] } +const_format = "0.2" +zip = { version = "6", features = [ + "deflate", + "deflate64", + "time", + "lzma", + "xz", +], default-features = false } +zip-extensions = { version = "0.8", features = [ + "deflate", + "lzma", + "xz", +], default-features = false } +java-properties = { git = "https://github.com/Kernel-SU/java-properties.git", branch = "master", default-features = false } +log = "0.4" +env_logger = { version = "0.11", default-features = false } +serde_json = "1" +encoding_rs = "0.8" +humansize = "2" +libc = "0.2" +extattr = "1" +jwalk = "0.8" +is_executable = "1" +nom = "8" +derive-new = "0.7" +rust-embed = { version = "8", features = [ + "debug-embed", + "compression", # must clean build after updating binaries +] } +which = "8" +getopts = "0.2" +sha256 = "1" +sha1 = "0.10" +tempfile = "3" +chrono = "0.4" +regex-lite = "0.1" +serde = { version = "1.0", features = ["derive"] } + +[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies] +rustix = { git = "https://github.com/Kernel-SU/rustix.git", rev = "4a53fbc7cb7a07cabe87125cc21dbc27db316259", features = [ + "all-apis", +] } +# some android specific dependencies which compiles under unix are also listed here for convenience of coding +android-properties = { version = "0.2", features = ["bionic-deprecated"] } + +[target.'cfg(target_os = "android")'.dependencies] +android_logger = { version = "0.15", default-features = false } + +[profile.release] +overflow-checks = false +codegen-units = 1 +lto = "fat" +opt-level = 3 +strip = true +split-debuginfo = "unpacked" diff --git a/userspace/ksud/bin/.gitignore b/userspace/ksud/bin/.gitignore new file mode 100644 index 0000000..1464b7e --- /dev/null +++ b/userspace/ksud/bin/.gitignore @@ -0,0 +1 @@ +**/*.ko \ No newline at end of file diff --git a/userspace/ksud/bin/aarch64/bootctl b/userspace/ksud/bin/aarch64/bootctl new file mode 100644 index 0000000..cf5c613 Binary files /dev/null and b/userspace/ksud/bin/aarch64/bootctl differ diff --git a/userspace/ksud/bin/aarch64/busybox b/userspace/ksud/bin/aarch64/busybox new file mode 100755 index 0000000..2fd8bcf Binary files /dev/null and b/userspace/ksud/bin/aarch64/busybox differ diff --git a/userspace/ksud/bin/aarch64/ksuinit b/userspace/ksud/bin/aarch64/ksuinit new file mode 100755 index 0000000..df67742 Binary files /dev/null and b/userspace/ksud/bin/aarch64/ksuinit differ diff --git a/userspace/ksud/bin/aarch64/resetprop b/userspace/ksud/bin/aarch64/resetprop new file mode 100644 index 0000000..dd58ca4 Binary files /dev/null and b/userspace/ksud/bin/aarch64/resetprop differ diff --git a/userspace/ksud/bin/arm/busybox b/userspace/ksud/bin/arm/busybox new file mode 100644 index 0000000..ea43875 Binary files /dev/null and b/userspace/ksud/bin/arm/busybox differ diff --git a/userspace/ksud/bin/arm/resetprop b/userspace/ksud/bin/arm/resetprop new file mode 100755 index 0000000..c84860e Binary files /dev/null and b/userspace/ksud/bin/arm/resetprop differ diff --git a/userspace/ksud/bin/x86_64/busybox b/userspace/ksud/bin/x86_64/busybox new file mode 100755 index 0000000..3603e74 Binary files /dev/null and b/userspace/ksud/bin/x86_64/busybox differ diff --git a/userspace/ksud/bin/x86_64/ksuinit b/userspace/ksud/bin/x86_64/ksuinit new file mode 100755 index 0000000..11dde60 Binary files /dev/null and b/userspace/ksud/bin/x86_64/ksuinit differ diff --git a/userspace/ksud/bin/x86_64/resetprop b/userspace/ksud/bin/x86_64/resetprop new file mode 100644 index 0000000..9048971 Binary files /dev/null and b/userspace/ksud/bin/x86_64/resetprop differ diff --git a/userspace/ksud/build.rs b/userspace/ksud/build.rs new file mode 100644 index 0000000..fd935d6 --- /dev/null +++ b/userspace/ksud/build.rs @@ -0,0 +1,47 @@ +use std::{env, fs::File, io::Write, path::Path, process::Command}; + +fn get_git_version() -> Result<(u32, String), std::io::Error> { + let output = Command::new("git") + .args(["rev-list", "--count", "HEAD"]) + .output()?; + + let output = output.stdout; + let version_code = String::from_utf8(output).expect("Failed to read git count stdout"); + let version_code: u32 = version_code + .trim() + .parse() + .map_err(|_| std::io::Error::other("Failed to parse git count"))?; + let version_code = 40000 - 2815 + version_code; // For historical reasons + + let version_name = String::from_utf8( + Command::new("git") + .args(["describe", "--tags", "--always"]) + .output()? + .stdout, + ) + .map_err(|_| std::io::Error::other("Failed to parse git count"))?; + let version_name = version_name.trim_start_matches('v').to_string(); + Ok((version_code, version_name)) +} + +fn main() { + let (code, name) = match get_git_version() { + Ok((code, name)) => (code, name), + Err(_) => { + // show warning if git is not installed + println!("cargo:warning=Failed to get git version, using 0.0.0"); + (0, "0.0.0".to_string()) + } + }; + let out_dir = env::var("OUT_DIR").expect("Failed to get $OUT_DIR"); + let out_dir = Path::new(&out_dir); + File::create(Path::new(out_dir).join("VERSION_CODE")) + .expect("Failed to create VERSION_CODE") + .write_all(code.to_string().as_bytes()) + .expect("Failed to write VERSION_CODE"); + + File::create(Path::new(out_dir).join("VERSION_NAME")) + .expect("Failed to create VERSION_NAME") + .write_all(name.trim().as_bytes()) + .expect("Failed to write VERSION_NAME"); +} diff --git a/userspace/ksud/src/apk_sign.rs b/userspace/ksud/src/apk_sign.rs new file mode 100644 index 0000000..4b2c4a9 --- /dev/null +++ b/userspace/ksud/src/apk_sign.rs @@ -0,0 +1,115 @@ +use anyhow::{Result, ensure}; +use std::io::{Read, Seek, SeekFrom}; + +pub fn get_apk_signature(apk: &str) -> Result<(u32, String)> { + let mut buffer = [0u8; 0x10]; + let mut size4 = [0u8; 4]; + let mut size8 = [0u8; 8]; + let mut size_of_block = [0u8; 8]; + + let mut f = std::fs::File::open(apk)?; + + let mut i = 0; + loop { + let mut n = [0u8; 2]; + f.seek(SeekFrom::End(-i - 2))?; + f.read_exact(&mut n)?; + + let n = u16::from_le_bytes(n); + if i64::from(n) == i { + f.seek(SeekFrom::Current(-22))?; + f.read_exact(&mut size4)?; + + if u32::from_le_bytes(size4) ^ 0xcafe_babe_u32 == 0xccfb_f1ee_u32 { + if i > 0 { + println!("warning: comment length is {i}"); + } + break; + } + } + + ensure!(n != 0xffff, "not a zip file"); + + i += 1; + } + + f.seek(SeekFrom::Current(12))?; + // offset + f.read_exact(&mut size4)?; + f.seek(SeekFrom::Start(u64::from(u32::from_le_bytes(size4)) - 0x18))?; + + f.read_exact(&mut size8)?; + f.read_exact(&mut buffer)?; + + ensure!(&buffer == b"APK Sig Block 42", "Can not found sig block"); + + let pos = u64::from(u32::from_le_bytes(size4)) - (u64::from_le_bytes(size8) + 0x8); + f.seek(SeekFrom::Start(pos))?; + f.read_exact(&mut size_of_block)?; + + ensure!(size_of_block == size8, "not a signed apk"); + + let mut v2_signing: Option<(u32, String)> = None; + let mut v3_signing_exist = false; + let mut v3_1_signing_exist = false; + + loop { + let mut id = [0u8; 4]; + let mut offset = 4u32; + + f.read_exact(&mut size8)?; // sequence length + if size8 == size_of_block { + break; + } + + f.read_exact(&mut id)?; // id + + let id = u32::from_le_bytes(id); + if id == 0x7109_871a_u32 { + v2_signing = Some(calc_cert_sha256(&mut f, &mut size4, &mut offset)?); + } else if id == 0xf053_68c0_u32 { + // v3 signature scheme + v3_signing_exist = true; + } else if id == 0x1b93_ad61_u32 { + // v3.1 signature scheme: credits to vvb2060 + v3_1_signing_exist = true; + } + + f.seek(SeekFrom::Current( + i64::from_le_bytes(size8) - i64::from(offset), + ))?; + } + + if v3_signing_exist || v3_1_signing_exist { + return Err(anyhow::anyhow!("Unexpected v3 signature found!",)); + } + + v2_signing.ok_or(anyhow::anyhow!("No signature found!")) +} + +fn calc_cert_sha256( + f: &mut std::fs::File, + size4: &mut [u8; 4], + offset: &mut u32, +) -> Result<(u32, String)> { + f.read_exact(size4)?; // signer-sequence length + f.read_exact(size4)?; // signer length + f.read_exact(size4)?; // signed data length + *offset += 0x4 * 3; + + f.read_exact(size4)?; // digests-sequence length + let pos = u32::from_le_bytes(*size4); // skip digests + f.seek(SeekFrom::Current(i64::from(pos)))?; + *offset += 0x4 + pos; + + f.read_exact(size4)?; // certificates length + f.read_exact(size4)?; // certificate length + *offset += 0x4 * 2; + + let cert_len = u32::from_le_bytes(*size4); + let mut cert: Vec = vec![0; cert_len as usize]; + f.read_exact(&mut cert)?; + *offset += cert_len; + + Ok((cert_len, sha256::digest(&cert))) +} diff --git a/userspace/ksud/src/assets.rs b/userspace/ksud/src/assets.rs new file mode 100644 index 0000000..e722cd6 --- /dev/null +++ b/userspace/ksud/src/assets.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use const_format::concatcp; +use rust_embed::RustEmbed; +use std::path::Path; + +use crate::{defs::BINARY_DIR, utils}; + +pub const RESETPROP_PATH: &str = concatcp!(BINARY_DIR, "resetprop"); +pub const BUSYBOX_PATH: &str = concatcp!(BINARY_DIR, "busybox"); +pub const BOOTCTL_PATH: &str = concatcp!(BINARY_DIR, "bootctl"); + +#[cfg(all(target_arch = "x86_64", target_os = "android"))] +#[derive(RustEmbed)] +#[folder = "bin/x86_64"] +struct Asset; + +// IF NOT x86_64 ANDROID, ie. macos, linux, windows, always use aarch64 +#[cfg(all(target_arch = "aarch64", target_os = "android"))] +#[derive(RustEmbed)] +#[folder = "bin/aarch64"] +struct Asset; + +#[cfg(all(target_arch = "arm", target_os = "android"))] +#[derive(RustEmbed)] +#[folder = "bin/arm"] +struct Asset; + +pub fn ensure_binaries(ignore_if_exist: bool) -> Result<()> { + for file in Asset::iter() { + if file == "ksuinit" || file.ends_with(".ko") { + // don't extract ksuinit and kernel modules + continue; + } + let asset = Asset::get(&file).ok_or(anyhow::anyhow!("asset not found: {}", file))?; + utils::ensure_binary(format!("{BINARY_DIR}{file}"), &asset.data, ignore_if_exist)? + } + Ok(()) +} + +pub fn copy_assets_to_file(name: &str, dst: impl AsRef) -> Result<()> { + let asset = Asset::get(name).ok_or(anyhow::anyhow!("asset not found: {}", name))?; + std::fs::write(dst, asset.data)?; + Ok(()) +} + +pub fn list_supported_kmi() -> Result> { + let mut list = Vec::new(); + for file in Asset::iter() { + // kmi_name = "xxx_kernelsu.ko" + if let Some(kmi) = file.strip_suffix("_kernelsu.ko") { + list.push(kmi.to_string()); + } + } + Ok(list) +} diff --git a/userspace/ksud/src/banner b/userspace/ksud/src/banner new file mode 100644 index 0000000..f2812bf --- /dev/null +++ b/userspace/ksud/src/banner @@ -0,0 +1,10 @@ + ____ _ _ ____ _ _ +/ ___| _ _| | _(_) ___|| | | | +\___ \| | | | |/ / \___ \| | | | + ___) | |_| | <| |___) | |_| | +|____/ \__,_|_|\_\_|____/ \___/ + _ _ _ _ + | | | | | |_ _ __ __ _ + | | | | | __| '__/ _\ | + | |_| | | |_| | | (_| | + \___/|_|\__|_| \__,_| \ No newline at end of file diff --git a/userspace/ksud/src/boot_patch.rs b/userspace/ksud/src/boot_patch.rs new file mode 100644 index 0000000..531b422 --- /dev/null +++ b/userspace/ksud/src/boot_patch.rs @@ -0,0 +1,791 @@ +#[cfg(unix)] +use std::{ + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use regex_lite::Regex; +use which::which; + +use crate::{ + assets, + defs::{self, BACKUP_FILENAME, KSU_BACKUP_DIR, KSU_BACKUP_FILE_PREFIX}, + utils, +}; + +#[cfg(target_os = "android")] +fn ensure_gki_kernel() -> Result<()> { + let version = get_kernel_version()?; + let is_gki = version.0 == 5 && version.1 >= 10 || version.2 > 5; + ensure!(is_gki, "only support GKI kernel"); + Ok(()) +} + +#[cfg(target_os = "android")] +fn get_kernel_version() -> Result<(i32, i32, i32)> { + let uname = rustix::system::uname(); + let version = uname.release().to_string_lossy(); + let re = Regex::new(r"(\d+)\.(\d+)\.(\d+)")?; + if let Some(captures) = re.captures(&version) { + let major = captures + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| anyhow!("Major version parse error"))?; + let minor = captures + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| anyhow!("Minor version parse error"))?; + let patch = captures + .get(3) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| anyhow!("Patch version parse error"))?; + Ok((major, minor, patch)) + } else { + Err(anyhow!("Invalid kernel version string")) + } +} + +#[cfg(target_os = "android")] +fn parse_kmi(version: &str) -> Result { + let re = Regex::new(r"(.* )?(\d+\.\d+)(\S+)?(android\d+)(.*)")?; + let cap = re + .captures(version) + .ok_or_else(|| anyhow::anyhow!("Failed to get KMI from boot/modules"))?; + let android_version = cap.get(4).map_or("", |m| m.as_str()); + let kernel_version = cap.get(2).map_or("", |m| m.as_str()); + Ok(format!("{android_version}-{kernel_version}")) +} + +#[cfg(target_os = "android")] +fn parse_kmi_from_uname() -> Result { + let uname = rustix::system::uname(); + let version = uname.release().to_string_lossy(); + parse_kmi(&version) +} + +#[cfg(target_os = "android")] +fn parse_kmi_from_modules() -> Result { + use std::io::BufRead; + // find a *.ko in /vendor/lib/modules + let modfile = std::fs::read_dir("/vendor/lib/modules")? + .filter_map(Result::ok) + .find(|entry| entry.path().extension().is_some_and(|ext| ext == "ko")) + .map(|entry| entry.path()) + .ok_or_else(|| anyhow!("No kernel module found"))?; + let output = Command::new("modinfo").arg(modfile).output()?; + for line in output.stdout.lines().map_while(Result::ok) { + if line.starts_with("vermagic") { + return parse_kmi(&line); + } + } + anyhow::bail!("Parse KMI from modules failed") +} + +#[cfg(target_os = "android")] +pub fn get_current_kmi() -> Result { + parse_kmi_from_uname().or_else(|_| parse_kmi_from_modules()) +} + +#[cfg(not(target_os = "android"))] +pub fn get_current_kmi() -> Result { + bail!("Unsupported platform") +} + +fn parse_kmi_from_kernel(kernel: &PathBuf, workdir: &Path) -> Result { + use std::fs::{File, copy}; + use std::io::{BufReader, Read}; + let kernel_path = workdir.join("kernel"); + copy(kernel, &kernel_path).context("Failed to copy kernel")?; + + let file = File::open(&kernel_path).context("Failed to open kernel file")?; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + reader + .read_to_end(&mut buffer) + .context("Failed to read kernel file")?; + + let printable_strings: Vec<&str> = buffer + .split(|&b| b == 0) + .filter_map(|slice| std::str::from_utf8(slice).ok()) + .filter(|s| s.chars().all(|c| c.is_ascii_graphic() || c == ' ')) + .collect(); + + let re = + Regex::new(r"(?:.* )?(\d+\.\d+)(?:\S+)?(android\d+)").context("Failed to compile regex")?; + for s in printable_strings { + if let Some(caps) = re.captures(s) + && let (Some(kernel_version), Some(android_version)) = (caps.get(1), caps.get(2)) + { + let kmi = format!("{}-{}", android_version.as_str(), kernel_version.as_str()); + return Ok(kmi); + } + } + println!("- Failed to get KMI version"); + bail!("Try to choose LKM manually") +} + +fn parse_kmi_from_boot(magiskboot: &Path, image: &PathBuf, workdir: &Path) -> Result { + let image_path = workdir.join("image"); + + std::fs::copy(image, &image_path).context("Failed to copy image")?; + + let status = Command::new(magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("unpack") + .arg(&image_path) + .status() + .context("Failed to execute magiskboot command")?; + + if !status.success() { + bail!( + "magiskboot unpack failed with status: {:?}", + status.code().unwrap() + ); + } + + parse_kmi_from_kernel(&image_path, workdir) +} + +fn do_cpio_cmd(magiskboot: &Path, workdir: &Path, cpio_path: &Path, cmd: &str) -> Result<()> { + let status = Command::new(magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("cpio") + .arg(cpio_path) + .arg(cmd) + .status()?; + ensure!(status.success(), "magiskboot cpio {} failed", cmd); + Ok(()) +} + +fn is_magisk_patched(magiskboot: &Path, workdir: &Path, cpio_path: &Path) -> Result { + let status = Command::new(magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("cpio") + .arg(cpio_path) + .arg("test") + .status()?; + // 0: stock, 1: magisk + Ok(status.code() == Some(1)) +} + +fn is_kernelsu_patched(magiskboot: &Path, workdir: &Path, cpio_path: &Path) -> Result { + let status = Command::new(magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("cpio") + .arg(cpio_path) + .arg("exists kernelsu.ko") + .status()?; + + Ok(status.success()) +} + +fn dd, Q: AsRef>(ifile: P, ofile: Q) -> Result<()> { + let status = Command::new("dd") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg(format!("if={}", ifile.as_ref().display())) + .arg(format!("of={}", ofile.as_ref().display())) + .status()?; + ensure!( + status.success(), + "dd if={:?} of={:?} failed", + ifile.as_ref(), + ofile.as_ref() + ); + Ok(()) +} + +pub fn restore( + image: Option, + magiskboot_path: Option, + flash: bool, +) -> Result<()> { + let tmpdir = tempfile::Builder::new() + .prefix("KernelSU") + .tempdir() + .context("create temp dir failed")?; + let workdir = tmpdir.path(); + let magiskboot = find_magiskboot(magiskboot_path, workdir)?; + + let kmi = get_current_kmi().unwrap_or_else(|_| String::from("")); + + let (bootimage, bootdevice) = find_boot_image(&image, &kmi, false, false, workdir, &None)?; + + println!("- Unpacking boot image"); + let status = Command::new(&magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("unpack") + .arg(bootimage.display().to_string()) + .status()?; + ensure!(status.success(), "magiskboot unpack failed"); + + let mut ramdisk = workdir.join("ramdisk.cpio"); + if !ramdisk.exists() { + ramdisk = workdir.join("vendor_ramdisk").join("init_boot.cpio") + } + if !ramdisk.exists() { + ramdisk = workdir.join("vendor_ramdisk").join("ramdisk.cpio"); + } + if !ramdisk.exists() { + bail!("No compatible ramdisk found.") + } + let ramdisk = ramdisk.as_path(); + let is_kernelsu_patched = is_kernelsu_patched(&magiskboot, workdir, ramdisk)?; + ensure!(is_kernelsu_patched, "boot image is not patched by KernelSU"); + + let mut new_boot = None; + let mut from_backup = false; + + #[cfg(target_os = "android")] + if do_cpio_cmd( + &magiskboot, + workdir, + ramdisk, + &format!("exists {BACKUP_FILENAME}"), + ) + .is_ok() + { + do_cpio_cmd( + &magiskboot, + workdir, + ramdisk, + &format!("extract {BACKUP_FILENAME} {BACKUP_FILENAME}"), + )?; + let sha = std::fs::read(workdir.join(BACKUP_FILENAME))?; + let sha = String::from_utf8(sha)?; + let sha = sha.trim(); + let backup_path = + PathBuf::from(KSU_BACKUP_DIR).join(format!("{KSU_BACKUP_FILE_PREFIX}{sha}")); + if backup_path.is_file() { + new_boot = Some(backup_path); + from_backup = true; + } else { + println!("- Warning: no backup {backup_path:?} found!"); + } + + if let Err(e) = clean_backup(sha) { + println!("- Warning: Cleanup backup image failed: {e}"); + } + } else { + println!("- Backup info is absent!"); + } + + if new_boot.is_none() { + // remove kernelsu.ko + do_cpio_cmd(&magiskboot, workdir, ramdisk, "rm kernelsu.ko")?; + + // if init.real exists, restore it + let status = do_cpio_cmd(&magiskboot, workdir, ramdisk, "exists init.real").is_ok(); + if status { + do_cpio_cmd(&magiskboot, workdir, ramdisk, "mv init.real init")?; + } + + println!("- Repacking boot image"); + let status = Command::new(&magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("repack") + .arg(&bootimage) + .status()?; + ensure!(status.success(), "magiskboot repack failed"); + new_boot = Some(workdir.join("new-boot.img")); + } + + let new_boot = new_boot.unwrap(); + + if image.is_some() { + // if image is specified, write to output file + let output_dir = std::env::current_dir()?; + let now = chrono::Utc::now(); + let output_image = output_dir.join(format!( + "kernelsu_restore_{}.img", + now.format("%Y%m%d_%H%M%S") + )); + + if from_backup || std::fs::rename(&new_boot, &output_image).is_err() { + std::fs::copy(&new_boot, &output_image).context("copy out new boot failed")?; + } + println!("- Output file is written to"); + println!("- {}", output_image.display().to_string().trim_matches('"')); + } + if flash { + if from_backup { + println!("- Flashing new boot image from {}", new_boot.display()); + } else { + println!("- Flashing new boot image"); + } + flash_boot(&bootdevice, new_boot)?; + } + println!("- Done!"); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn patch( + image: Option, + kernel: Option, + kmod: Option, + init: Option, + ota: bool, + flash: bool, + out: Option, + magiskboot: Option, + kmi: Option, + partition: Option, +) -> Result<()> { + let result = do_patch( + image, kernel, kmod, init, ota, flash, out, magiskboot, kmi, partition, + ); + if let Err(ref e) = result { + println!("- Install Error: {e}"); + } + result +} + +#[allow(clippy::too_many_arguments)] +fn do_patch( + image: Option, + kernel: Option, + kmod: Option, + init: Option, + ota: bool, + flash: bool, + out: Option, + magiskboot_path: Option, + kmi: Option, + partition: Option, +) -> Result<()> { + println!(include_str!("banner")); + + let patch_file = image.is_some(); + + #[cfg(target_os = "android")] + if !patch_file { + ensure_gki_kernel()?; + } + + let is_replace_kernel = kernel.is_some(); + + if is_replace_kernel { + ensure!( + init.is_none() && kmod.is_none(), + "init and module must not be specified." + ); + } + + let tmpdir = tempfile::Builder::new() + .prefix("KernelSU") + .tempdir() + .context("create temp dir failed")?; + let workdir = tmpdir.path(); + + // extract magiskboot + let magiskboot = find_magiskboot(magiskboot_path, workdir)?; + + let kmi = if let Some(kmi) = kmi { + kmi + } else { + match get_current_kmi() { + Ok(value) => value, + Err(e) => { + println!("- {e}"); + if let Some(image_path) = &image { + println!( + "- Trying to auto detect KMI version for {}", + image_path.to_str().unwrap() + ); + parse_kmi_from_boot(&magiskboot, image_path, tmpdir.path())? + } else if let Some(kernel_path) = &kernel { + println!( + "- Trying to auto detect KMI version for {}", + kernel_path.to_str().unwrap() + ); + parse_kmi_from_kernel(kernel_path, tmpdir.path())? + } else { + "".to_string() + } + } + } + }; + + let (bootimage, bootdevice) = + find_boot_image(&image, &kmi, ota, is_replace_kernel, workdir, &partition)?; + + let bootimage = bootimage.as_path(); + + // try extract magiskboot/bootctl + let _ = assets::ensure_binaries(false); + + if let Some(kernel) = kernel { + std::fs::copy(kernel, workdir.join("kernel")).context("copy kernel from failed")?; + } + + println!("- Preparing assets"); + + let kmod_file = workdir.join("kernelsu.ko"); + if let Some(kmod) = kmod { + std::fs::copy(kmod, kmod_file).context("copy kernel module failed")?; + } else { + // If kmod is not specified, extract from assets + println!("- KMI: {kmi}"); + let name = format!("{kmi}_kernelsu.ko"); + assets::copy_assets_to_file(&name, kmod_file) + .with_context(|| format!("Failed to copy {name}"))?; + }; + + let init_file = workdir.join("init"); + if let Some(init) = init { + std::fs::copy(init, init_file).context("copy init failed")?; + } else { + assets::copy_assets_to_file("ksuinit", init_file).context("copy ksuinit failed")?; + } + + println!("- Unpacking boot image"); + let status = Command::new(&magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("unpack") + .arg(bootimage) + .status()?; + ensure!(status.success(), "magiskboot unpack failed"); + + let mut ramdisk = workdir.join("ramdisk.cpio"); + if !ramdisk.exists() { + ramdisk = workdir.join("vendor_ramdisk").join("init_boot.cpio") + } + if !ramdisk.exists() { + ramdisk = workdir.join("vendor_ramdisk").join("ramdisk.cpio"); + } + if !ramdisk.exists() { + println!("- No ramdisk, create by default"); + ramdisk = "ramdisk.cpio".into(); + } + let ramdisk = ramdisk.as_path(); + let is_magisk_patched = is_magisk_patched(&magiskboot, workdir, ramdisk)?; + ensure!(!is_magisk_patched, "Cannot work with Magisk patched image"); + + println!("- Adding KernelSU LKM"); + let is_kernelsu_patched = is_kernelsu_patched(&magiskboot, workdir, ramdisk)?; + + let mut need_backup = false; + if !is_kernelsu_patched { + // kernelsu.ko is not exist, backup init if necessary + let status = do_cpio_cmd(&magiskboot, workdir, ramdisk, "exists init"); + if status.is_ok() { + do_cpio_cmd(&magiskboot, workdir, ramdisk, "mv init init.real")?; + } + need_backup = flash; + } + + do_cpio_cmd(&magiskboot, workdir, ramdisk, "add 0755 init init")?; + do_cpio_cmd( + &magiskboot, + workdir, + ramdisk, + "add 0755 kernelsu.ko kernelsu.ko", + )?; + + #[cfg(target_os = "android")] + if need_backup && let Err(e) = do_backup(&magiskboot, workdir, ramdisk, bootimage) { + println!("- Backup stock image failed: {e}"); + } + + println!("- Repacking boot image"); + // magiskboot repack boot.img + let status = Command::new(&magiskboot) + .current_dir(workdir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .arg("repack") + .arg(bootimage) + .status()?; + ensure!(status.success(), "magiskboot repack failed"); + let new_boot = workdir.join("new-boot.img"); + + if patch_file { + // if image is specified, write to output file + let output_dir = out.unwrap_or(std::env::current_dir()?); + let now = chrono::Utc::now(); + let output_image = output_dir.join(format!( + "kernelsu_patched_{}.img", + now.format("%Y%m%d_%H%M%S") + )); + + if std::fs::rename(&new_boot, &output_image).is_err() { + std::fs::copy(&new_boot, &output_image).context("copy out new boot failed")?; + } + println!("- Output file is written to"); + println!("- {}", output_image.display().to_string().trim_matches('"')); + } + + if flash { + println!("- Flashing new boot image"); + flash_boot(&bootdevice, new_boot)?; + + if ota { + post_ota()?; + } + } + + println!("- Done!"); + Ok(()) +} + +#[cfg(target_os = "android")] +fn calculate_sha1(file_path: impl AsRef) -> Result { + use sha1::Digest; + use std::io::Read; + let mut file = std::fs::File::open(file_path.as_ref())?; + let mut hasher = sha1::Sha1::new(); + let mut buffer = [0; 1024]; + + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + let result = hasher.finalize(); + Ok(format!("{result:x}")) +} + +#[cfg(target_os = "android")] +fn do_backup(magiskboot: &Path, workdir: &Path, cpio_path: &Path, image: &Path) -> Result<()> { + let sha1 = calculate_sha1(image)?; + let filename = format!("{KSU_BACKUP_FILE_PREFIX}{sha1}"); + + println!("- Backup stock boot image"); + // magiskboot cpio ramdisk.cpio 'add 0755 $BACKUP_FILENAME' + let target = format!("{KSU_BACKUP_DIR}{filename}"); + std::fs::copy(image, &target).with_context(|| format!("backup to {target}"))?; + std::fs::write(workdir.join(BACKUP_FILENAME), sha1.as_bytes()).context("write sha1")?; + do_cpio_cmd( + magiskboot, + workdir, + cpio_path, + &format!("add 0755 {BACKUP_FILENAME} {BACKUP_FILENAME}"), + )?; + println!("- Stock image has been backup to"); + println!("- {target}"); + Ok(()) +} + +#[cfg(target_os = "android")] +fn clean_backup(sha1: &str) -> Result<()> { + println!("- Clean up backup"); + let backup_name = format!("{KSU_BACKUP_FILE_PREFIX}{sha1}"); + let dir = std::fs::read_dir(defs::KSU_BACKUP_DIR)?; + for entry in dir.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(name) = path.file_name() { + let name = name.to_string_lossy().to_string(); + if name != backup_name + && name.starts_with(KSU_BACKUP_FILE_PREFIX) + && std::fs::remove_file(path).is_ok() + { + println!("- removed {name}"); + } + } + } + Ok(()) +} + +fn flash_boot(bootdevice: &Option, new_boot: PathBuf) -> Result<()> { + let Some(bootdevice) = bootdevice else { + bail!("boot device not found") + }; + let status = Command::new("blockdev") + .arg("--setrw") + .arg(bootdevice) + .status()?; + ensure!(status.success(), "set boot device rw failed"); + dd(new_boot, bootdevice).context("flash boot failed")?; + Ok(()) +} + +fn find_magiskboot(magiskboot_path: Option, workdir: &Path) -> Result { + let magiskboot = { + if which("magiskboot").is_ok() { + let _ = assets::ensure_binaries(true); + "magiskboot".into() + } else { + // magiskboot is not in $PATH, use builtin or specified one + let magiskboot = if let Some(magiskboot_path) = magiskboot_path { + std::fs::canonicalize(magiskboot_path)? + } else { + let magiskboot_path = workdir.join("magiskboot"); + assets::copy_assets_to_file("magiskboot", &magiskboot_path) + .context("copy magiskboot failed")?; + magiskboot_path + }; + ensure!(magiskboot.exists(), "{magiskboot:?} is not exist"); + #[cfg(unix)] + let _ = std::fs::set_permissions(&magiskboot, std::fs::Permissions::from_mode(0o755)); + magiskboot + } + }; + Ok(magiskboot) +} + +fn find_boot_image( + image: &Option, + kmi: &str, + ota: bool, + is_replace_kernel: bool, + workdir: &Path, + partition: &Option, +) -> Result<(PathBuf, Option)> { + let bootimage; + let mut bootdevice = None; + if let Some(ref image) = *image { + ensure!(image.exists(), "boot image not found"); + bootimage = std::fs::canonicalize(image)?; + } else { + if cfg!(not(target_os = "android")) { + println!("- Current OS is not android, refusing auto bootimage/bootdevice detection"); + bail!("Please specify a boot image"); + } + + let slot_suffix = get_slot_suffix(ota); + let boot_partition_name = choose_boot_partition(kmi, is_replace_kernel, partition); + let boot_partition = format!("/dev/block/by-name/{boot_partition_name}{slot_suffix}"); + + println!("- Bootdevice: {boot_partition}"); + let tmp_boot_path = workdir.join("boot.img"); + + dd(&boot_partition, &tmp_boot_path)?; + + ensure!(tmp_boot_path.exists(), "boot image not found"); + + bootimage = tmp_boot_path; + bootdevice = Some(boot_partition); + }; + Ok((bootimage, bootdevice)) +} + +#[cfg(target_os = "android")] +pub fn choose_boot_partition( + kmi: &str, + is_replace_kernel: bool, + partition: &Option, +) -> String { + let slot_suffix = get_slot_suffix(false); + let skip_init_boot = kmi.starts_with("android12-"); + let init_boot_exist = Path::new(&format!("/dev/block/by-name/init_boot{slot_suffix}")).exists(); + + // if specific partition is specified, use it + if let Some(part) = partition { + return match part.as_str() { + "boot" | "init_boot" | "vendor_boot" => part.clone(), + _ => "boot".to_string(), + }; + } + + // if init_boot exists and not skipping it, use it + if !is_replace_kernel && init_boot_exist && !skip_init_boot { + return "init_boot".to_string(); + } + + "boot".to_string() +} + +#[cfg(not(target_os = "android"))] +pub fn choose_boot_partition( + _kmi: &str, + _is_replace_kernel: bool, + _partition: &Option, +) -> String { + "boot".to_string() +} + +#[cfg(target_os = "android")] +pub fn get_slot_suffix(ota: bool) -> String { + let mut slot_suffix = utils::getprop("ro.boot.slot_suffix").unwrap_or_else(|| String::from("")); + if !slot_suffix.is_empty() && ota { + if slot_suffix == "_a" { + slot_suffix = "_b".to_string() + } else { + slot_suffix = "_a".to_string() + } + } + slot_suffix +} + +#[cfg(not(target_os = "android"))] +pub fn get_slot_suffix(_ota: bool) -> String { + String::new() +} + +#[cfg(target_os = "android")] +pub fn list_available_partitions() -> Vec { + let slot_suffix = get_slot_suffix(false); + let candidates = vec!["boot", "init_boot", "vendor_boot"]; + candidates + .into_iter() + .filter(|name| Path::new(&format!("/dev/block/by-name/{}{}", name, slot_suffix)).exists()) + .map(|s| s.to_string()) + .collect() +} + +#[cfg(not(target_os = "android"))] +pub fn list_available_partitions() -> Vec { + Vec::new() +} + +fn post_ota() -> Result<()> { + use crate::defs::ADB_DIR; + use assets::BOOTCTL_PATH; + let status = Command::new(BOOTCTL_PATH).arg("hal-info").status()?; + if !status.success() { + return Ok(()); + } + + let current_slot = Command::new(BOOTCTL_PATH) + .arg("get-current-slot") + .output()? + .stdout; + let current_slot = String::from_utf8(current_slot)?; + let current_slot = current_slot.trim(); + let target_slot = if current_slot == "0" { 1 } else { 0 }; + + Command::new(BOOTCTL_PATH) + .arg(format!("set-active-boot-slot {target_slot}")) + .status()?; + + let post_fs_data = std::path::Path::new(ADB_DIR).join("post-fs-data.d"); + utils::ensure_dir_exists(&post_fs_data)?; + let post_ota_sh = post_fs_data.join("post_ota.sh"); + + let sh_content = format!( + r###" +{BOOTCTL_PATH} mark-boot-successful +rm -f {BOOTCTL_PATH} +rm -f /data/adb/post-fs-data.d/post_ota.sh +"### + ); + + std::fs::write(&post_ota_sh, sh_content)?; + #[cfg(unix)] + std::fs::set_permissions(post_ota_sh, std::fs::Permissions::from_mode(0o755))?; + + Ok(()) +} diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs new file mode 100644 index 0000000..7afbe99 --- /dev/null +++ b/userspace/ksud/src/cli.rs @@ -0,0 +1,771 @@ +use anyhow::{Ok, Result}; +use clap::Parser; +use std::path::PathBuf; + +#[cfg(target_os = "android")] +use android_logger::Config; +#[cfg(target_os = "android")] +use log::LevelFilter; + +use crate::{apk_sign, assets, debug, defs, init_event, ksucalls, module, utils}; + +/// KernelSU userspace cli +#[derive(Parser, Debug)] +#[command(author, version = defs::VERSION_NAME, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand, Debug)] +enum Commands { + /// Manage KernelSU modules + Module { + #[command(subcommand)] + command: Module, + }, + + /// Trigger `post-fs-data` event + PostFsData, + + /// Trigger `service` event + Services, + + /// Trigger `boot-complete` event + BootCompleted, + + /// Install KernelSU userspace component to system + Install { + #[arg(long, default_value = None)] + magiskboot: Option, + }, + + /// Uninstall KernelSU modules and itself(LKM Only) + Uninstall { + /// magiskboot path, if not specified, will search from $PATH + #[arg(long, default_value = None)] + magiskboot: Option, + }, + + /// SELinux policy Patch tool + Sepolicy { + #[command(subcommand)] + command: Sepolicy, + }, + + /// Manage App Profiles + Profile { + #[command(subcommand)] + command: Profile, + }, + + /// Manage kernel features + Feature { + #[command(subcommand)] + command: Feature, + }, + + /// Patch boot or init_boot images to apply KernelSU + BootPatch { + /// boot image path, if not specified, will try to find the boot image automatically + #[arg(short, long)] + boot: Option, + + /// kernel image path to replace + #[arg(short, long)] + kernel: Option, + + /// LKM module path to replace, if not specified, will use the builtin one + #[arg(short, long)] + module: Option, + + /// init to be replaced + #[arg(short, long, requires("module"))] + init: Option, + + /// will use another slot when boot image is not specified + #[arg(short = 'u', long, default_value = "false")] + ota: bool, + + /// Flash it to boot partition after patch + #[arg(short, long, default_value = "false")] + flash: bool, + + /// output path, if not specified, will use current directory + #[arg(short, long, default_value = None)] + out: Option, + + /// magiskboot path, if not specified, will search from $PATH + #[arg(long, default_value = None)] + magiskboot: Option, + + /// KMI version, if specified, will use the specified KMI + #[arg(long, default_value = None)] + kmi: Option, + + /// target partition override (init_boot | boot | vendor_boot) + #[arg(long, default_value = None)] + partition: Option, + }, + + /// Restore boot or init_boot images patched by KernelSU + BootRestore { + /// boot image path, if not specified, will try to find the boot image automatically + #[arg(short, long)] + boot: Option, + + /// Flash it to boot partition after patch + #[arg(short, long, default_value = "false")] + flash: bool, + + /// magiskboot path, if not specified, will search from $PATH + #[arg(long, default_value = None)] + magiskboot: Option, + }, + + /// Show boot information + BootInfo { + #[command(subcommand)] + command: BootInfo, + }, + + /// KPM module manager + #[cfg(target_arch = "aarch64")] + Kpm { + #[command(subcommand)] + command: kpm_cmd::Kpm, + }, + + /// Manage kernel umount paths + Umount { + #[command(subcommand)] + command: Umount, + }, + + /// For developers + Debug { + #[command(subcommand)] + command: Debug, + }, + /// Kernel interface + Kernel { + #[command(subcommand)] + command: Kernel, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum BootInfo { + /// show current kmi version + CurrentKmi, + + /// show supported kmi versions + SupportedKmis, + + /// check if device is A/B capable + IsAbDevice, + + /// show auto-selected boot partition name + DefaultPartition, + + /// list available partitions for current or OTA toggled slot + AvailablePartitions, + + /// show slot suffix for current or OTA toggled slot + SlotSuffix { + /// toggle to another slot + #[arg(short = 'u', long, default_value = "false")] + ota: bool, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum Debug { + /// Set the manager app, kernel CONFIG_KSU_DEBUG should be enabled. + SetManager { + /// manager package name + #[arg(default_value_t = String::from("com.sukisu.ultra"))] + apk: String, + }, + + /// Get apk size and hash + GetSign { + /// apk path + apk: String, + }, + + /// Root Shell + Su { + /// switch to gloabl mount namespace + #[arg(short, long, default_value = "false")] + global_mnt: bool, + }, + + /// Get kernel version + Version, + + /// For testing + Test, + + /// Process mark management + Mark { + #[command(subcommand)] + command: MarkCommand, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum MarkCommand { + /// Get mark status for a process (or all) + Get { + /// target pid (0 for total count) + #[arg(default_value = "0")] + pid: i32, + }, + + /// Mark a process + Mark { + /// target pid (0 for all processes) + #[arg(default_value = "0")] + pid: i32, + }, + + /// Unmark a process + Unmark { + /// target pid (0 for all processes) + #[arg(default_value = "0")] + pid: i32, + }, + + /// Refresh mark for all running processes + Refresh, +} + +#[derive(clap::Subcommand, Debug)] +enum Sepolicy { + /// Patch sepolicy + Patch { + /// sepolicy statements + sepolicy: String, + }, + + /// Apply sepolicy from file + Apply { + /// sepolicy file path + file: String, + }, + + /// Check if sepolicy statement is supported/valid + Check { + /// sepolicy statements + sepolicy: String, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum Module { + /// Install module + Install { + /// module zip file path + zip: String, + }, + + /// Undo module uninstall mark + UndoUninstall { + /// module id + id: String, + }, + + /// Uninstall module + Uninstall { + /// module id + id: String, + }, + + /// enable module + Enable { + /// module id + id: String, + }, + + /// disable module + Disable { + // module id + id: String, + }, + + /// run action for module + Action { + // module id + id: String, + }, + + /// list all modules + List, + + /// manage module configuration + Config { + #[command(subcommand)] + command: ModuleConfigCmd, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum ModuleConfigCmd { + /// Get a config value + Get { + /// config key + key: String, + }, + + /// Set a config value + Set { + /// config key + key: String, + /// config value + value: String, + /// use temporary config (cleared on reboot) + #[arg(short, long)] + temp: bool, + }, + + /// List all config entries + List, + + /// Delete a config entry + Delete { + /// config key + key: String, + /// delete from temporary config + #[arg(short, long)] + temp: bool, + }, + + /// Clear all config entries + Clear { + /// clear temporary config + #[arg(short, long)] + temp: bool, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum Profile { + /// get root profile's selinux policy of + GetSepolicy { + /// package name + package: String, + }, + + /// set root profile's selinux policy of to + SetSepolicy { + /// package name + package: String, + /// policy statements + policy: String, + }, + + /// get template of + GetTemplate { + /// template id + id: String, + }, + + /// set template of to