diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
new file mode 100644
index 00000000..73017f1b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -0,0 +1,61 @@
+name: š Bug report
+description: Create a new bug report.
+title: 'bug:
'
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # ReVanced Manager bug report
+
+ Please check for existing issues [here](https://github.com/revanced/revanced-manager/labels/bug) before creating a new one.
+ - type: textarea
+ attributes:
+ label: Bug description
+ description: |
+ - Describe your bug in detail
+ - Add steps to reproduce the bug if possible (Step 1. Download some files. Step 2. ...)
+ - Add images and videos if possible
+ - List selected patches if applicable
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Version of ReVanced Manager and version & name of application you tried to patch
+ validations:
+ required: true
+ - type: dropdown
+ attributes:
+ label: Installation type
+ options:
+ - Non-root
+ - Root
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Device logs
+ description: Export logs in ReVanced Manager settings.
+ render: shell
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Patcher logs
+ description: Export logs in "Patcher" screen.
+ render: shell
+ validations:
+ required: false
+ - type: checkboxes
+ attributes:
+ label: Acknowledgements
+ description: Your issue will be closed if you don't follow the checklist below!
+ options:
+ - label: This request is not a duplicate of an existing issue.
+ required: true
+ - label: I have chosen an appropriate title.
+ required: true
+ - label: All requested information has been provided properly.
+ required: true
+ - label: The issue is solely related to the ReVanced Manager
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..ec4bb386
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature-issue.yml b/.github/ISSUE_TEMPLATE/feature-issue.yml
new file mode 100644
index 00000000..ca76ef00
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-issue.yml
@@ -0,0 +1,42 @@
+name: ā Feature request
+description: Create a new feature request.
+title: 'feat: '
+labels: [feature request]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # ReVanced Manager feature request
+
+ Please check for existing feature requests [here](https://github.com/revanced/revanced-manager/labels/bug) before creating a new one.
+ - type: textarea
+ attributes:
+ label: Feature description
+ description: Describe your feature in detail.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Motivation
+ description: Explain why the lack of it is a problem.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Additional context
+ description: In case there is something else you want to add.
+ validations:
+ required: false
+ - type: checkboxes
+ attributes:
+ label: Acknowledgements
+ description: Your issue will be closed if you don't follow the checklist below!
+ options:
+ - label: This request is not a duplicate of an existing issue.
+ required: true
+ - label: I have chosen an appropriate title.
+ required: true
+ - label: All requested information has been provided properly.
+ required: true
+ - label: The issue is solely related to the ReVanced Manager
+ required: true
diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml
index 7fd76dc2..e4a04351 100644
--- a/.github/workflows/pr-build.yml
+++ b/.github/workflows/pr-build.yml
@@ -17,17 +17,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Set up cache
- uses: actions/cache@v3
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
with:
- path: |
- ${{ runner.home }}/.gradle/caches
- ${{ runner.home }}/.gradle/wrapper
- .gradle
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ java-version: '17'
+ distribution: 'temurin'
- - name: Set up Java
- run: echo "JAVA_HOME=$JAVA_HOME_17_X64" >> $GITHUB_ENV
+ - name: Setup Gradle
+ uses: gradle/gradle-build-action@v2
- name: Build with Gradle
env:
diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml
index ec1e0714..8e273987 100644
--- a/.github/workflows/release-build.yml
+++ b/.github/workflows/release-build.yml
@@ -14,8 +14,16 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- - name: Set up Java
- run: echo "JAVA_HOME=$JAVA_HOME_17_X64" >> $GITHUB_ENV
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/gradle-build-action@v2
+ with:
+ cache-disabled: true
- name: Build with Gradle
env:
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..f288702d
--- /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/app/build.gradle.kts b/app/build.gradle.kts
index e5c3f484..36daff74 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,13 +9,13 @@ plugins {
android {
namespace = "app.revanced.manager"
- compileSdk = 33
- buildToolsVersion = "33.0.2"
+ compileSdk = 34
+ buildToolsVersion = "34.0.0"
defaultConfig {
applicationId = "app.revanced.manager"
minSdk = 26
- targetSdk = 33
+ targetSdk = 34
versionCode = 1
versionName = "0.0.1"
resourceConfigurations.addAll(listOf(
@@ -54,6 +54,7 @@ android {
includeInApk = false
includeInBundle = false
}
+
packaging {
resources.excludes.addAll(listOf(
"/prebuilt/**",
@@ -156,6 +157,9 @@ dependencies {
implementation(libs.ktor.content.negotiation)
implementation(libs.ktor.serialization)
- // Markdown to HTML
- implementation(libs.markdown)
+ // Markdown
+ implementation(libs.markdown.renderer)
+
+ // Fading Edges
+ implementation(libs.fading.edges)
}
diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json
index 543d7a70..0fb6425d 100644
--- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json
+++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "5515d164bc8f713201506d42a02d337f",
+ "identityHash": "802fa2fda94b930bf0ebb85d195f1022",
"entities": [
{
"tableName": "patch_bundles",
@@ -160,7 +160,7 @@
},
{
"tableName": "downloaded_app",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `file` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))",
"fields": [
{
"fieldPath": "packageName",
@@ -175,8 +175,8 @@
"notNull": true
},
{
- "fieldPath": "file",
- "columnName": "file",
+ "fieldPath": "directory",
+ "columnName": "directory",
"affinity": "TEXT",
"notNull": true
}
@@ -295,12 +295,119 @@
]
}
]
+ },
+ {
+ "tableName": "option_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchBundle",
+ "columnName": "patch_bundle",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packageName",
+ "columnName": "package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_option_groups_patch_bundle_package_name",
+ "unique": true,
+ "columnNames": [
+ "patch_bundle",
+ "package_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "patch_bundles",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "patch_bundle"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "options",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "group",
+ "columnName": "group",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchName",
+ "columnName": "patch_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "group",
+ "patch_name",
+ "key"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "option_groups",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "group"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5515d164bc8f713201506d42a02d337f')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '802fa2fda94b930bf0ebb85d195f1022')"
]
}
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 452c968a..3acb1c04 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
+ tools:targetApi="34">
+
+
+
+
when (destination) {
is Destination.Dashboard -> DashboardScreen(
- onSettingsClick = { navController.navigate(Destination.Settings) },
+ onSettingsClick = { navController.navigate(Destination.Settings()) },
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
- onAppClick = { installedApp -> navController.navigate(Destination.ApplicationInfo(installedApp)) }
+ onAppClick = { installedApp ->
+ navController.navigate(
+ Destination.InstalledApplicationInfo(
+ installedApp
+ )
+ )
+ }
)
- is Destination.ApplicationInfo -> AppInfoScreen(
+ is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
onPatchClick = { packageName, patchesSelection ->
- navController.navigate(Destination.VersionSelector(packageName, patchesSelection))
+ navController.navigate(
+ Destination.VersionSelector(
+ packageName,
+ patchesSelection
+ )
+ )
},
onBackClick = { navController.pop() },
- viewModel = getViewModel { parametersOf(destination.installedApp) }
+ viewModel = getComposeViewModel { parametersOf(destination.installedApp) }
)
is Destination.Settings -> SettingsScreen(
- onBackClick = { navController.pop() }
+ onBackClick = { navController.pop() },
+ startDestination = destination.startDestination
)
is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.VersionSelector(it)) },
- onStorageClick = { navController.navigate(Destination.PatchesSelector(it)) },
+ onStorageClick = {
+ navController.navigate(
+ Destination.SelectedApplicationInfo(
+ it
+ )
+ )
+ },
onBackClick = { navController.pop() }
)
@@ -88,36 +140,46 @@ class MainActivity : ComponentActivity() {
onBackClick = { navController.pop() },
onAppClick = { selectedApp ->
navController.navigate(
- Destination.PatchesSelector(
+ Destination.SelectedApplicationInfo(
selectedApp,
+ destination.patchesSelection,
+ )
+ )
+ },
+ viewModel = getComposeViewModel {
+ parametersOf(
+ destination.packageName,
+ destination.patchesSelection
+ )
+ }
+ )
+
+ is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
+ onPatchClick = { app, patches, options ->
+ navController.navigate(
+ Destination.Installer(
+ app, patches, options
+ )
+ )
+ },
+ onBackClick = navController::pop,
+ vm = getComposeViewModel {
+ parametersOf(
+ SelectedAppInfoViewModel.Params(
+ destination.selectedApp,
destination.patchesSelection
)
)
- },
- viewModel = getViewModel { parametersOf(destination.packageName, destination.patchesSelection) }
- )
-
- is Destination.PatchesSelector -> PatchesSelectorScreen(
- onBackClick = { navController.pop() },
- onPatchClick = { patches, options ->
- navController.navigate(
- Destination.Installer(
- destination.selectedApp,
- patches,
- options
- )
- )
- },
- vm = getViewModel { parametersOf(destination) }
+ }
)
is Destination.Installer -> InstallerScreen(
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
- vm = getViewModel { parametersOf(destination) }
+ vm = getComposeViewModel { parametersOf(destination) }
)
}
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt
index e1a0f81b..0440a7c2 100644
--- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt
+++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt
@@ -13,15 +13,19 @@ import app.revanced.manager.data.room.selection.SelectedPatch
import app.revanced.manager.data.room.selection.SelectionDao
import app.revanced.manager.data.room.bundles.PatchBundleDao
import app.revanced.manager.data.room.bundles.PatchBundleEntity
+import app.revanced.manager.data.room.options.Option
+import app.revanced.manager.data.room.options.OptionDao
+import app.revanced.manager.data.room.options.OptionGroup
import kotlin.random.Random
-@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class], version = 1)
+@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun patchBundleDao(): PatchBundleDao
abstract fun selectionDao(): SelectionDao
abstract fun downloadedAppDao(): DownloadedAppDao
abstract fun installedAppDao(): InstalledAppDao
+ abstract fun optionDao(): OptionDao
companion object {
fun generateUid() = Random.Default.nextInt()
diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt
index f8aa073d..7de50382 100644
--- a/app/src/main/java/app/revanced/manager/data/room/Converters.kt
+++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt
@@ -16,5 +16,5 @@ class Converters {
fun fileFromString(value: String) = File(value)
@TypeConverter
- fun fileToString(file: File): String = file.absolutePath
+ fun fileToString(file: File): String = file.path
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt
index a30063ff..60d1561d 100644
--- a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt
@@ -11,5 +11,5 @@ import java.io.File
data class DownloadedApp(
@ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "version") val version: String,
- @ColumnInfo(name = "file") val file: File,
+ @ColumnInfo(name = "directory") val directory: File,
)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt
index 71172493..90d40b9f 100644
--- a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt
@@ -6,6 +6,7 @@ import androidx.room.Insert
import androidx.room.MapInfo
import androidx.room.Query
import androidx.room.Transaction
+import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
@@ -24,17 +25,21 @@ interface InstalledAppDao {
suspend fun getPatchesSelection(packageName: String): Map>
@Transaction
- suspend fun insertApp(installedApp: InstalledApp, appliedPatches: List) {
- insertApp(installedApp)
+ suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List) {
+ upsertApp(installedApp)
+ deleteAppliedPatches(installedApp.currentPackageName)
insertAppliedPatches(appliedPatches)
}
- @Insert
- suspend fun insertApp(installedApp: InstalledApp)
+ @Upsert
+ suspend fun upsertApp(installedApp: InstalledApp)
@Insert
suspend fun insertAppliedPatches(appliedPatches: List)
+ @Query("DELETE FROM applied_patch WHERE package_name = :packageName")
+ suspend fun deleteAppliedPatches(packageName: String)
+
@Delete
suspend fun delete(installedApp: InstalledApp)
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/options/Option.kt b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt
new file mode 100644
index 00000000..3a70a9a5
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt
@@ -0,0 +1,23 @@
+package app.revanced.manager.data.room.options
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+
+@Entity(
+ tableName = "options",
+ primaryKeys = ["group", "patch_name", "key"],
+ foreignKeys = [ForeignKey(
+ OptionGroup::class,
+ parentColumns = ["uid"],
+ childColumns = ["group"],
+ onDelete = ForeignKey.CASCADE
+ )]
+)
+data class Option(
+ @ColumnInfo(name = "group") val group: Int,
+ @ColumnInfo(name = "patch_name") val patchName: String,
+ @ColumnInfo(name = "key") val key: String,
+ // Encoded as Json.
+ @ColumnInfo(name = "value") val value: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt
new file mode 100644
index 00000000..fa343a6d
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt
@@ -0,0 +1,51 @@
+package app.revanced.manager.data.room.options
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.MapInfo
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+abstract class OptionDao {
+ @Transaction
+ @MapInfo(keyColumn = "patch_bundle")
+ @Query(
+ "SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" +
+ " LEFT JOIN options ON uid = options.`group`" +
+ " WHERE package_name = :packageName"
+ )
+ abstract suspend fun getOptions(packageName: String): Map>
+
+ @Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName")
+ abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int?
+
+ @Query("SELECT package_name FROM option_groups")
+ abstract fun getPackagesWithOptions(): Flow>
+
+ @Insert
+ abstract suspend fun createOptionGroup(group: OptionGroup)
+
+ @Query("DELETE FROM option_groups WHERE patch_bundle = :uid")
+ abstract suspend fun clearForPatchBundle(uid: Int)
+
+ @Query("DELETE FROM option_groups WHERE package_name = :packageName")
+ abstract suspend fun clearForPackage(packageName: String)
+
+ @Query("DELETE FROM option_groups")
+ abstract suspend fun reset()
+
+ @Insert
+ protected abstract suspend fun insertOptions(patches: List)
+
+ @Query("DELETE FROM options WHERE `group` = :groupId")
+ protected abstract suspend fun clearGroup(groupId: Int)
+
+ @Transaction
+ open suspend fun updateOptions(options: Map>) =
+ options.forEach { (groupId, options) ->
+ clearGroup(groupId)
+ insertOptions(options)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt
new file mode 100644
index 00000000..df35dc99
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt
@@ -0,0 +1,24 @@
+package app.revanced.manager.data.room.options
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+
+@Entity(
+ tableName = "option_groups",
+ foreignKeys = [ForeignKey(
+ PatchBundleEntity::class,
+ parentColumns = ["uid"],
+ childColumns = ["patch_bundle"],
+ onDelete = ForeignKey.CASCADE
+ )],
+ indices = [Index(value = ["patch_bundle", "package_name"], unique = true)]
+)
+data class OptionGroup(
+ @PrimaryKey val uid: Int,
+ @ColumnInfo(name = "patch_bundle") val patchBundle: Int,
+ @ColumnInfo(name = "package_name") val packageName: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt
index 630c5d66..7cf6dd79 100644
--- a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt
+++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt
@@ -49,7 +49,7 @@ abstract class SelectionDao {
@Transaction
open suspend fun updateSelections(selections: Map>) =
- selections.map { (selectionUid, patches) ->
+ selections.forEach { (selectionUid, patches) ->
clearSelection(selectionUid)
selectPatches(patches.map { SelectedPatch(selectionUid, it) })
}
diff --git a/app/src/main/java/app/revanced/manager/di/HttpModule.kt b/app/src/main/java/app/revanced/manager/di/HttpModule.kt
index 38621b0c..1d827ce6 100644
--- a/app/src/main/java/app/revanced/manager/di/HttpModule.kt
+++ b/app/src/main/java/app/revanced/manager/di/HttpModule.kt
@@ -1,9 +1,11 @@
package app.revanced.manager.di
import android.content.Context
+import app.revanced.manager.BuildConfig
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.UserAgent
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
@@ -40,6 +42,9 @@ val httpModule = module {
install(HttpTimeout) {
socketTimeoutMillis = 10000
}
+ install(UserAgent) {
+ agent = "ReVanced-Manager/${BuildConfig.VERSION_CODE}"
+ }
}
fun provideJson() = Json {
diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt
index a5420a5c..df2d7018 100644
--- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt
+++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt
@@ -17,7 +17,11 @@ val repositoryModule = module {
singleOf(::NetworkInfo)
singleOf(::PatchBundlePersistenceRepository)
singleOf(::PatchSelectionRepository)
- singleOf(::PatchBundleRepository)
+ singleOf(::PatchOptionsRepository)
+ singleOf(::PatchBundleRepository) {
+ // It is best to load patch bundles ASAP
+ createdAtStart()
+ }
singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository)
singleOf(::InstalledAppRepository)
diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt
index 5f12a2ad..dbcba6a0 100644
--- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt
+++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt
@@ -7,17 +7,18 @@ import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::MainViewModel)
viewModelOf(::DashboardViewModel)
+ viewModelOf(::SelectedAppInfoViewModel)
viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::InstallerViewModel)
- viewModelOf(::UpdateProgressViewModel)
- viewModelOf(::ManagerUpdateChangelogViewModel)
+ viewModelOf(::UpdateViewModel)
+ viewModelOf(::ChangelogsViewModel)
viewModelOf(::ImportExportViewModel)
viewModelOf(::ContributorViewModel)
viewModelOf(::DownloadsViewModel)
viewModelOf(::InstalledAppsViewModel)
- viewModelOf(::AppInfoViewModel)
+ viewModelOf(::InstalledAppInfoViewModel)
}
diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt
index 43f86e72..9b6d1d60 100644
--- a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt
+++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt
@@ -10,8 +10,10 @@ import java.nio.file.StandardCopyOption
class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSource(name, id, directory) {
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
withContext(Dispatchers.IO) {
- patches?.let {
- Files.copy(it, patchesFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ patches?.let { inputStream ->
+ patchBundleOutputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
}
integrations?.let {
Files.copy(it, this@LocalPatchBundle.integrationsFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt
index f8b8e74c..ebff077c 100644
--- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt
+++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt
@@ -1,11 +1,14 @@
package app.revanced.manager.domain.bundles
+import android.util.Log
import androidx.compose.runtime.Stable
import app.revanced.manager.patcher.patch.PatchBundle
+import app.revanced.manager.util.tag
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import java.io.File
+import java.io.OutputStream
/**
* A [PatchBundle] source.
@@ -23,12 +26,23 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
*/
fun hasInstalled() = patchesFile.exists()
+ protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) {
+ // Android 14+ requires dex containers to be readonly.
+ try {
+ setWritable(true, true)
+ outputStream()
+ } finally {
+ setReadOnly()
+ }
+ }
+
private fun load(): State {
if (!hasInstalled()) return State.Missing
return try {
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
} catch (t: Throwable) {
+ Log.e(tag, "Failed to load patch bundle $name", t)
State.Failed(t)
}
}
@@ -40,7 +54,7 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
sealed interface State {
fun patchBundleOrNull(): PatchBundle? = null
- object Missing : State
+ data object Missing : State
data class Failed(val throwable: Throwable) : State
data class Loaded(val bundle: PatchBundle) : State {
override fun patchBundleOrNull() = bundle
diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt
index 93c945a8..295cc2bd 100644
--- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt
+++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt
@@ -33,16 +33,19 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
private suspend fun download(info: BundleInfo) = withContext(Dispatchers.IO) {
val (patches, integrations) = info
coroutineScope {
- mapOf(
- patches.url to patchesFile,
- integrations.url to integrationsFile
- ).forEach { (asset, file) ->
- launch {
- http.download(file) {
- url(asset)
+ launch {
+ patchBundleOutputStream().use {
+ http.streamTo(it) {
+ url(patches.url)
}
}
}
+
+ launch {
+ http.download(integrationsFile) {
+ url(integrations.url)
+ }
+ }
}
saveVersion(patches.version, integrations.version)
@@ -101,7 +104,7 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
override suspend fun getLatestInfo() = coroutineScope {
fun getAssetAsync(repo: String, mime: String) = async(Dispatchers.IO) {
api
- .getRelease(repo)
+ .getLatestRelease(repo)
.getOrThrow()
.let {
BundleAsset(it.metadata.tag, it.findAssetByType(mime).downloadUrl)
diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt
index 5c08ba17..34a41ab8 100644
--- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt
+++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt
@@ -12,6 +12,7 @@ class PreferencesManager(
val api = stringPreference("api_url", "https://api.revanced.app")
+ val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true)
val allowExperimental = booleanPreference("allow_experimental", false)
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
@@ -19,7 +20,7 @@ class PreferencesManager(
val preferSplits = booleanPreference("prefer_splits", false)
- val showAutoUpdatesDialog = booleanPreference("show_auto_updates_dialog", true)
+ val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt
index 7035c6c4..fe339a2e 100644
--- a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt
+++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt
@@ -1,34 +1,61 @@
package app.revanced.manager.domain.repository
+import android.app.Application
+import android.content.Context
import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
+import app.revanced.manager.network.downloader.AppDownloader
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File
class DownloadedAppRepository(
+ app: Application,
db: AppDatabase
) {
+ private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE)
private val dao = db.downloadedAppDao()
fun getAll() = dao.getAllApps().distinctUntilChanged()
- suspend fun get(packageName: String, version: String) = dao.get(packageName, version)
+ fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory))
+ private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
- suspend fun add(
- packageName: String,
- version: String,
- file: File
- ) = dao.insert(
- DownloadedApp(
- packageName = packageName,
- version = version,
- file = file
- )
- )
+ suspend fun download(
+ app: AppDownloader.App,
+ preferSplits: Boolean,
+ onDownload: suspend (downloadProgress: Pair?) -> Unit = {},
+ ): File {
+ this.get(app.packageName, app.version)?.let { downloaded ->
+ return getApkFileForApp(downloaded)
+ }
+
+ // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
+ val relativePath = File(generateUid().toString())
+ val savePath = dir.resolve(relativePath).also { it.mkdirs() }
+
+ try {
+ app.download(savePath, preferSplits, onDownload)
+
+ dao.insert(DownloadedApp(
+ packageName = app.packageName,
+ version = app.version,
+ directory = relativePath,
+ ))
+ } catch (e: Exception) {
+ savePath.deleteRecursively()
+ throw e
+ }
+
+ // Return the Apk file.
+ return getApkFileForDir(savePath)
+ }
+
+ suspend fun get(packageName: String, version: String) = dao.get(packageName, version)
suspend fun delete(downloadedApps: Collection) {
downloadedApps.forEach {
- it.file.deleteRecursively()
+ dir.resolve(it.directory).deleteRecursively()
}
dao.delete(downloadedApps)
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt
index 99a37ed9..afba73ba 100644
--- a/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt
+++ b/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt
@@ -19,14 +19,14 @@ class InstalledAppRepository(
suspend fun getAppliedPatches(packageName: String): PatchesSelection =
dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() }
- suspend fun add(
+ suspend fun addOrUpdate(
currentPackageName: String,
originalPackageName: String,
version: String,
installType: InstallType,
patchesSelection: PatchesSelection
) {
- dao.insertApp(
+ dao.upsertApp(
InstalledApp(
currentPackageName = currentPackageName,
originalPackageName = originalPackageName,
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt
index ffdf7d13..2f7a8fe3 100644
--- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt
+++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt
@@ -3,6 +3,7 @@ package app.revanced.manager.domain.repository
import android.app.Application
import android.content.Context
import android.util.Log
+import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.domain.bundles.APIPatchBundle
@@ -13,18 +14,19 @@ import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.util.flatMapLatestAndCombine
import app.revanced.manager.util.tag
+import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import java.io.InputStream
class PatchBundleRepository(
- app: Application,
+ private val app: Application,
private val persistenceRepo: PatchBundlePersistenceRepository,
private val networkInfo: NetworkInfo,
) {
@@ -124,20 +126,24 @@ class PatchBundleRepository(
reload()
}
- suspend fun redownloadRemoteBundles() = getBundlesByType().forEach { it.downloadLatest() }
+ suspend fun redownloadRemoteBundles() =
+ getBundlesByType().forEach { it.downloadLatest() }
- suspend fun updateCheck() = supervisorScope {
- if (!networkInfo.isSafe()) {
- Log.d(tag, "Skipping update check because the network is down or metered.")
- return@supervisorScope
- }
+ suspend fun updateCheck() =
+ uiSafe(app, R.string.source_download_fail, "Failed to update bundles") {
+ coroutineScope {
+ if (!networkInfo.isSafe()) {
+ Log.d(tag, "Skipping update check because the network is down or metered.")
+ return@coroutineScope
+ }
- getBundlesByType().forEach {
- launch {
- if (!it.propsFlow().first().autoUpdate) return@launch
- Log.d(tag, "Updating patch bundle: ${it.name}")
- it.update()
+ getBundlesByType().forEach {
+ launch {
+ if (!it.propsFlow().first().autoUpdate) return@launch
+ Log.d(tag, "Updating patch bundle: ${it.name}")
+ it.update()
+ }
+ }
}
}
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt
new file mode 100644
index 00000000..43ca3273
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt
@@ -0,0 +1,89 @@
+package app.revanced.manager.domain.repository
+
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.options.Option
+import app.revanced.manager.data.room.options.OptionGroup
+import app.revanced.manager.util.Options
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.floatOrNull
+import kotlinx.serialization.json.intOrNull
+
+class PatchOptionsRepository(db: AppDatabase) {
+ private val dao = db.optionDao()
+
+ private suspend fun getOrCreateGroup(bundleUid: Int, packageName: String) =
+ dao.getGroupId(bundleUid, packageName) ?: OptionGroup(
+ uid = AppDatabase.generateUid(),
+ patchBundle = bundleUid,
+ packageName = packageName
+ ).also { dao.createOptionGroup(it) }.uid
+
+ suspend fun getOptions(packageName: String): Options {
+ val options = dao.getOptions(packageName)
+ // Bundle -> Patches
+ return buildMap>>(options.size) {
+ options.forEach { (sourceUid, bundlePatchOptionsList) ->
+ // Patches -> Patch options
+ this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
+ val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
+
+ patchOptions[option.key] = deserialize(option.value)
+
+ bundlePatchOptions
+ }
+ }
+ }
+ }
+
+ suspend fun saveOptions(packageName: String, options: Options) =
+ dao.updateOptions(options.entries.associate { (sourceUid, bundlePatchOptions) ->
+ val groupId = getOrCreateGroup(sourceUid, packageName)
+
+ groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
+ patchOptions.mapNotNull { (key, value) ->
+ val serialized = serialize(value)
+ ?: return@mapNotNull null // Don't save options that we can't serialize.
+
+ Option(groupId, patchName, key, serialized)
+ }
+ }
+ })
+
+ fun getPackagesWithSavedOptions() =
+ dao.getPackagesWithOptions().map(Iterable::toSet).distinctUntilChanged()
+
+ suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
+ suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
+ suspend fun reset() = dao.reset()
+
+ private companion object {
+ fun deserialize(value: String): Any? {
+ val primitive = Json.decodeFromString(value)
+
+ return when {
+ primitive.isString -> primitive.content
+ primitive is JsonNull -> null
+ else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull
+ }
+ }
+
+ fun serialize(value: Any?): String? {
+ val primitive = when (value) {
+ null -> JsonNull
+ is String -> JsonPrimitive(value)
+ is Int -> JsonPrimitive(value)
+ is Float -> JsonPrimitive(value)
+ is Boolean -> JsonPrimitive(value)
+ else -> return null
+ }
+
+ return Json.encodeToString(primitive)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt
index 3367bcf2..1bc5fdd6 100644
--- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt
+++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt
@@ -1,11 +1,8 @@
package app.revanced.manager.network.api
import app.revanced.manager.domain.manager.PreferencesManager
-import app.revanced.manager.network.dto.Asset
-import app.revanced.manager.network.dto.ReVancedLatestRelease
import app.revanced.manager.network.dto.ReVancedRelease
import app.revanced.manager.network.service.ReVancedService
-import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.network.utils.transform
class ReVancedAPI(
@@ -16,7 +13,9 @@ class ReVancedAPI(
suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories }
- suspend fun getRelease(name: String) = service.getRelease(apiUrl(), name).transform { it.release }
+ suspend fun getLatestRelease(name: String) = service.getLatestRelease(apiUrl(), name).transform { it.release }
+
+ suspend fun getReleases(name: String) = service.getReleases(apiUrl(), name).transform { it.releases }
companion object Extensions {
fun ReVancedRelease.findAssetByType(mime: String) = assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime)
diff --git a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt
index 3a055ef5..30c6fbee 100644
--- a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt
+++ b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt
@@ -171,7 +171,7 @@ class APKMirror : AppDownloader, KoinComponent {
saveDirectory: File,
preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair?) -> Unit
- ): File {
+ ) {
val variants = httpClient.getHtml { url(apkMirror + downloadLink) }
.div {
withClass = "variants-table"
@@ -246,18 +246,10 @@ class APKMirror : AppDownloader, KoinComponent {
}
}
- val saveLocation = if (variant.apkType == APKType.BUNDLE)
- saveDirectory.resolve(version).also { it.mkdirs() }
- else
- saveDirectory.resolve("$version.apk")
+ val targetFile = saveDirectory.resolve("base.apk")
try {
- val downloadLocation = if (variant.apkType == APKType.BUNDLE)
- saveLocation.resolve("temp.zip")
- else
- saveLocation
-
- httpClient.download(downloadLocation) {
+ httpClient.download(targetFile) {
url(apkMirror + downloadLink)
onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
@@ -267,16 +259,11 @@ class APKMirror : AppDownloader, KoinComponent {
if (variant.apkType == APKType.BUNDLE) {
// TODO: Extract temp.zip
- downloadLocation.delete()
+ targetFile.delete()
}
- } catch (e: Exception) {
- saveLocation.deleteRecursively()
- throw e
} finally {
onDownload(null)
}
-
- return saveLocation
}
}
diff --git a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt
index a6a17622..dcefa26e 100644
--- a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt
+++ b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt
@@ -22,7 +22,6 @@ interface AppDownloader {
saveDirectory: File,
preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair?) -> Unit = {}
- ): File
+ )
}
-
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt
index 416e0629..d7fe2bbf 100644
--- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt
+++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt
@@ -8,6 +8,11 @@ data class ReVancedLatestRelease(
val release: ReVancedRelease,
)
+@Serializable
+data class ReVancedReleases(
+ val releases: List
+)
+
@Serializable
data class ReVancedRelease(
val metadata: ReVancedReleaseMeta,
@@ -28,6 +33,7 @@ data class ReVancedReleaseMeta(
@Serializable
data class Asset(
val name: String,
+ @SerialName("download_count") val downloadCount: Int,
@SerialName("browser_download_url") val downloadUrl: String,
@SerialName("content_type") val contentType: String
)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt
index 3781c3b4..e0b69aa6 100644
--- a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt
+++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt
@@ -18,8 +18,11 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.isNotEmpty
import io.ktor.utils.io.core.readBytes
import it.skrape.core.htmlDocument
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
+import java.io.OutputStream
/**
* @author Aliucord Authors, DiamondMiner88
@@ -49,7 +52,10 @@ class HttpService(
null
}
- Log.e(tag, "Failed to fetch: API error, http status: ${response.status}, body: $body")
+ Log.e(
+ tag,
+ "Failed to fetch: API error, http status: ${response.status}, body: $body"
+ )
APIResponse.Error(APIError(response.status, body))
}
} catch (t: Throwable) {
@@ -59,20 +65,19 @@ class HttpService(
return response
}
- suspend fun download(
- saveLocation: File,
+ suspend fun streamTo(
+ outputStream: OutputStream,
builder: HttpRequestBuilder.() -> Unit
) {
http.prepareGet(builder).execute { httpResponse ->
if (httpResponse.status.isSuccess()) {
-
- saveLocation.outputStream().use { stream ->
- val channel: ByteReadChannel = httpResponse.body()
+ val channel: ByteReadChannel = httpResponse.body()
+ withContext(Dispatchers.IO) {
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (packet.isNotEmpty) {
val bytes = packet.readBytes()
- stream.write(bytes)
+ outputStream.write(bytes)
}
}
}
@@ -83,6 +88,11 @@ class HttpService(
}
}
+ suspend fun download(
+ saveLocation: File,
+ builder: HttpRequestBuilder.() -> Unit
+ ) = saveLocation.outputStream().use { streamTo(it, builder) }
+
suspend fun getHtml(builder: HttpRequestBuilder.() -> Unit) = htmlDocument(
html = http.get(builder).bodyAsText()
)
diff --git a/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt b/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt
index b5681afe..54516b4d 100644
--- a/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt
+++ b/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt
@@ -2,6 +2,7 @@ package app.revanced.manager.network.service
import app.revanced.manager.network.dto.ReVancedLatestRelease
import app.revanced.manager.network.dto.ReVancedGitRepositories
+import app.revanced.manager.network.dto.ReVancedReleases
import app.revanced.manager.network.utils.APIResponse
import io.ktor.client.request.*
import kotlinx.coroutines.Dispatchers
@@ -10,13 +11,20 @@ import kotlinx.coroutines.withContext
class ReVancedService(
private val client: HttpService,
) {
- suspend fun getRelease(api: String, repo: String): APIResponse =
+ suspend fun getLatestRelease(api: String, repo: String): APIResponse =
withContext(Dispatchers.IO) {
client.request {
url("$api/v2/$repo/releases/latest")
}
}
+ suspend fun getReleases(api: String, repo: String): APIResponse =
+ withContext(Dispatchers.IO) {
+ client.request {
+ url("$api/v2/$repo/releases")
+ }
+ }
+
suspend fun getContributors(api: String): APIResponse =
withContext(Dispatchers.IO) {
client.request {
diff --git a/app/src/main/java/app/revanced/manager/patcher/Session.kt b/app/src/main/java/app/revanced/manager/patcher/Session.kt
index 35b80e5e..337bd195 100644
--- a/app/src/main/java/app/revanced/manager/patcher/Session.kt
+++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt
@@ -20,6 +20,7 @@ class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
+ multithreadingDexFileWriter: Boolean,
private val logger: ManagerLogger,
private val input: File,
private val onStepSucceeded: suspend () -> Unit
@@ -30,7 +31,8 @@ class Session(
inputFile = input,
resourceCachePath = tempDir.resolve("aapt-resources"),
frameworkFileDirectory = frameworkDir,
- aaptBinaryPath = aaptPath
+ aaptBinaryPath = aaptPath,
+ multithreadingDexFileWriter = multithreadingDexFileWriter,
)
)
diff --git a/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt b/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt
index 25e26be4..959768e6 100644
--- a/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt
+++ b/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt
@@ -1,9 +1,14 @@
package app.revanced.manager.patcher.aapt
import android.content.Context
+import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS
import java.io.File
object Aapt {
+ private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64")
+
+ fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty()
+
fun binary(context: Context): File? {
return File(context.applicationInfo.nativeLibraryDir).resolveAapt()
}
diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt
index 7c5b2ffd..6da4fab4 100644
--- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt
+++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt
@@ -9,7 +9,10 @@ import java.io.File
class PatchBundle(private val loader: Iterable>, val integrations: File?) {
constructor(bundleJar: File, integrations: File?) : this(
object : Iterable> {
- private fun load(): Iterable> = PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null)
+ private fun load(): Iterable> {
+ bundleJar.setReadOnly()
+ return PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null)
+ }
override fun iterator(): Iterator> = load().iterator()
},
diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt
index 8002fa99..4914a07a 100644
--- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt
+++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt
@@ -56,15 +56,15 @@ data class Option(
val key: String,
val description: String,
val required: Boolean,
- val type: Class>,
- val defaultValue: Any?
+ val type: String,
+ val default: Any?
) {
constructor(option: PatchOption<*>) : this(
option.title ?: option.key,
option.key,
option.description.orEmpty(),
option.required,
- option::class.java,
- option.value
+ option.valueType,
+ option.default,
)
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt
index b138b3bd..938f7b12 100644
--- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt
+++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt
@@ -26,7 +26,12 @@ class Step(
val state: State = State.WAITING
)
-class PatcherProgressManager(context: Context, selectedPatches: List, selectedApp: SelectedApp, downloadProgress: StateFlow?>) {
+class PatcherProgressManager(
+ context: Context,
+ selectedPatches: List,
+ selectedApp: SelectedApp,
+ downloadProgress: StateFlow?>
+) {
val steps = generateSteps(context, selectedPatches, selectedApp, downloadProgress)
private var currentStep: StepKey? = StepKey(0, 0)
@@ -87,12 +92,20 @@ class PatcherProgressManager(context: Context, selectedPatches: List, se
selectedPatches.map { SubStep(it) }.toImmutableList()
)
- fun generateSteps(context: Context, selectedPatches: List, selectedApp: SelectedApp, downloadProgress: StateFlow?>? = null) = mutableListOf(
+ fun generateSteps(
+ context: Context,
+ selectedPatches: List,
+ selectedApp: SelectedApp,
+ downloadProgress: StateFlow?>? = null
+ ) = mutableListOf(
Step(
R.string.patcher_step_group_prepare,
listOfNotNull(
SubStep(context.getString(R.string.patcher_step_load_patches)),
- SubStep("Download apk", progress = downloadProgress).takeIf { selectedApp is SelectedApp.Download },
+ SubStep(
+ "Download apk",
+ progress = downloadProgress
+ ).takeIf { selectedApp is SelectedApp.Download },
SubStep(context.getString(R.string.patcher_step_unpack)),
SubStep(context.getString(R.string.patcher_step_integrations))
).toImmutableList()
@@ -100,7 +113,10 @@ class PatcherProgressManager(context: Context, selectedPatches: List, se
generatePatchesStep(selectedPatches),
Step(
R.string.patcher_step_group_saving,
- persistentListOf(SubStep(context.getString(R.string.patcher_step_write_patched)))
+ persistentListOf(
+ SubStep(context.getString(R.string.patcher_step_write_patched)),
+ SubStep(context.getString(R.string.patcher_step_sign_apk))
+ )
)
)
}
diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
index 864eb343..4779677a 100644
--- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
+++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
@@ -6,7 +6,9 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
+import android.os.Build
import android.os.PowerManager
import android.util.Log
import android.view.WindowManager
@@ -17,6 +19,7 @@ import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.installer.RootInstaller
+import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
@@ -48,6 +51,7 @@ class PatcherWorker(
private val patchBundleRepository: PatchBundleRepository by inject()
private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject()
+ private val keystoreManager: KeystoreManager by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject()
private val fs: Filesystem by inject()
@@ -71,7 +75,12 @@ class PatcherWorker(
private fun String.logFmt() = "$logPrefix $this"
}
- override suspend fun getForegroundInfo() = ForegroundInfo(1, createNotification())
+ override suspend fun getForegroundInfo() =
+ ForegroundInfo(
+ 1,
+ createNotification(),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0
+ )
private fun createNotification(): Notification {
val notificationIntent = Intent(applicationContext, PatcherWorker::class.java)
@@ -152,6 +161,8 @@ class PatcherWorker(
progressFlow.value = progressManager.getProgress().toImmutableList()
}
+ val patchedApk = fs.tempDir.resolve("patched.apk")
+
return try {
if (args.input is SelectedApp.Installed) {
@@ -168,11 +179,11 @@ class PatcherWorker(
.mapValues { (_, bundle) -> bundle.patchClasses(args.packageName) }
// Set all patch options.
- args.options.forEach { (bundle, configuredPatchOptions) ->
+ args.options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach
- configuredPatchOptions.forEach { (patchName, options) ->
+ bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
val patchOptions = patches.single { it.name == patchName }.options
- options.forEach { (key, value) ->
+ configuredPatchOptions.forEach { (key, value) ->
patchOptions[key] = value
}
}
@@ -190,19 +201,11 @@ class PatcherWorker(
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
- val savePath = applicationContext.filesDir.resolve("downloaded-apps")
- .resolve(args.input.packageName).also { it.mkdirs() }
-
- selectedApp.app.download(
- savePath,
+ downloadedAppRepository.download(
+ selectedApp.app,
prefs.preferSplits.get(),
onDownload = { downloadProgress.emit(it) }
).also {
- downloadedAppRepository.add(
- args.input.packageName,
- args.input.version,
- it
- )
args.setInputFile(it)
updateProgress() // Downloading
}
@@ -216,13 +219,17 @@ class PatcherWorker(
fs.tempDir.absolutePath,
frameworkPath,
aaptPath,
+ prefs.multithreadingDexFileWriter.get(),
args.logger,
inputFile,
onStepSucceeded = ::updateProgress
).use { session ->
- session.run(File(args.output), patches, integrations)
+ session.run(patchedApk, patches, integrations)
}
+ keystoreManager.sign(patchedApk, File(args.output))
+ updateProgress() // Signing
+
Log.i(tag, "Patching succeeded".logFmt())
progressManager.success()
Result.success()
@@ -232,6 +239,7 @@ class PatcherWorker(
Result.failure()
} finally {
updateProgress(false)
+ patchedApk.delete()
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/service/InstallService.kt b/app/src/main/java/app/revanced/manager/service/InstallService.kt
index 420a5dc0..7bf2d213 100644
--- a/app/src/main/java/app/revanced/manager/service/InstallService.kt
+++ b/app/src/main/java/app/revanced/manager/service/InstallService.kt
@@ -29,6 +29,7 @@ class InstallService : Service() {
else -> {
sendBroadcast(Intent().apply {
action = APP_INSTALL_ACTION
+ `package` = packageName
putExtra(EXTRA_INSTALL_STATUS, extraStatus)
putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage)
putExtra(EXTRA_PACKAGE_NAME, extraPackageName)
diff --git a/app/src/main/java/app/revanced/manager/service/UninstallService.kt b/app/src/main/java/app/revanced/manager/service/UninstallService.kt
index cefd3528..6bb4d4fd 100644
--- a/app/src/main/java/app/revanced/manager/service/UninstallService.kt
+++ b/app/src/main/java/app/revanced/manager/service/UninstallService.kt
@@ -31,7 +31,7 @@ class UninstallService : Service() {
else -> {
sendBroadcast(Intent().apply {
action = APP_UNINSTALL_ACTION
-
+ `package` = packageName
putExtra(EXTRA_UNINSTALL_STATUS, extraStatus)
putExtra(EXTRA_UNINSTALL_STATUS_MESSAGE, extraStatusMessage)
})
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt b/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt
new file mode 100644
index 00000000..6d45b20b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt
@@ -0,0 +1,39 @@
+package app.revanced.manager.ui.component
+
+import android.content.pm.PackageInfo
+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.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun AppInfo(appInfo: PackageInfo?, placeholderLabel: String? = null, extraContent: @Composable () -> Unit = {}) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AppIcon(
+ appInfo,
+ contentDescription = null,
+ modifier = Modifier
+ .size(100.dp)
+ .padding(bottom = 5.dp)
+ )
+
+ AppLabel(
+ appInfo,
+ modifier = Modifier.padding(top = 16.dp),
+ style = MaterialTheme.typography.titleLarge,
+ defaultText = placeholderLabel
+ )
+
+ extraContent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt b/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt
index f7ca27ae..b07b23e6 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt
@@ -18,6 +18,6 @@ fun GroupHeader(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.padding(16.dp).semantics { heading() }.then(modifier)
+ modifier = Modifier.padding(24.dp).semantics { heading() }.then(modifier)
)
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt b/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt
index 6773b15a..1b79d8f8 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt
@@ -1,113 +1,32 @@
package app.revanced.manager.ui.component
-import android.annotation.SuppressLint
-import android.view.MotionEvent
-import android.view.ViewGroup
-import android.webkit.WebResourceRequest
-import android.webkit.WebView
-import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import app.revanced.manager.util.hexCode
-import app.revanced.manager.util.openUrl
-import com.google.accompanist.web.AccompanistWebViewClient
-import com.google.accompanist.web.WebView
-import com.google.accompanist.web.rememberWebViewStateWithHTMLData
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.mikepenz.markdown.compose.Markdown
+import com.mikepenz.markdown.model.markdownColor
+import com.mikepenz.markdown.model.markdownTypography
@Composable
-@SuppressLint("ClickableViewAccessibility")
fun Markdown(
- text: String,
- modifier: Modifier = Modifier
+ text: String
) {
- val ctx = LocalContext.current
- val state = rememberWebViewStateWithHTMLData(data = generateMdHtml(source = text))
- val client = remember {
- object : AccompanistWebViewClient() {
- override fun shouldOverrideUrlLoading(
- view: WebView?,
- request: WebResourceRequest?
- ): Boolean {
- if (request != null) ctx.openUrl(request.url.toString())
- return true
- }
- }
- }
+ val markdown = text.trimIndent()
- WebView(
- state,
- modifier = Modifier
- .background(Color.Transparent)
- .then(modifier),
- client = client,
- captureBackPresses = false,
- onCreated = {
- it.setBackgroundColor(android.graphics.Color.TRANSPARENT)
- it.isVerticalScrollBarEnabled = false
- it.isHorizontalScrollBarEnabled = false
- it.setOnTouchListener { _, event -> event.action == MotionEvent.ACTION_MOVE }
- it.layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.WRAP_CONTENT
- )
- }
+ Markdown(
+ content = markdown,
+ colors = markdownColor(
+ text = MaterialTheme.colorScheme.onSurfaceVariant,
+ codeBackground = MaterialTheme.colorScheme.secondaryContainer,
+ codeText = MaterialTheme.colorScheme.onSecondaryContainer
+ ),
+ typography = markdownTypography(
+ h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ h2 = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ h3 = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ text = MaterialTheme.typography.bodyMedium,
+ list = MaterialTheme.typography.bodyMedium
+ )
)
-}
-
-@Composable
-fun generateMdHtml(
- source: String,
- wrap: Boolean = false,
- headingColor: Color = MaterialTheme.colorScheme.onSurface,
- textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
- linkColor: Color = MaterialTheme.colorScheme.primary
-) = remember(source, wrap, headingColor, textColor, linkColor) {
- """
-
-
- Markdown
-
-
-
-
- $source
-
- """
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt
index 4c23afd4..3b17de5d 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt
@@ -1,56 +1,177 @@
package app.revanced.manager.ui.component
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.unit.dp
+import app.revanced.manager.R
@Composable
fun NotificationCard(
- color: Color,
- icon: ImageVector,
+ isWarning: Boolean = false,
+ title: String? = null,
text: String,
- content: @Composable () -> Unit
+ icon: ImageVector,
+ actions: (@Composable () -> Unit)?
) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(28.dp))
- .background(color)
- ) {
+ val color =
+ if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
+
+ NotificationCardInstance(isWarning = isWarning) {
+ Column(
+ modifier = Modifier.padding(if (title != null) 20.dp else 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ if (title != null) {
+ Icon(
+ modifier = Modifier.size(36.dp),
+ imageVector = icon,
+ contentDescription = null,
+ tint = color,
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = color,
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = icon,
+ contentDescription = null,
+ tint = color,
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ }
+ }
+ actions?.invoke()
+ }
+ }
+}
+
+@Composable
+fun NotificationCard(
+ isWarning: Boolean = false,
+ title: String? = null,
+ text: String,
+ icon: ImageVector,
+ onDismiss: (() -> Unit)? = null,
+ primaryAction: (() -> Unit)? = null
+) {
+ val color =
+ if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
+
+ NotificationCardInstance(isWarning = isWarning, onClick = primaryAction) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(
- 16.dp,
- Alignment.Start
- )
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
+ modifier = Modifier.size(if (title != null) 36.dp else 24.dp),
imageVector = icon,
contentDescription = null,
+ tint = color,
)
- Text(
- modifier = Modifier.width(220.dp),
- text = text,
- style = MaterialTheme.typography.bodyMedium
- )
+ if (title != null) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = color,
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ }
+ } else {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ }
+ if (onDismiss != null) {
+ IconButton(onClick = onDismiss) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.close),
+ tint = color,
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NotificationCardInstance(
+ isWarning: Boolean = false,
+ onClick: (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ val colors =
+ CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)
+ val modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .clip(RoundedCornerShape(24.dp))
+
+ if (onClick != null) {
+ Card(
+ onClick = onClick,
+ colors = colors,
+ modifier = modifier
+ ) {
+ content()
+ }
+ } else {
+ Card(
+ colors = colors,
+ modifier = modifier,
+ ) {
content()
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt
index 385c2269..33eb2d69 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt
@@ -22,10 +22,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.TextInputDialog
+import app.revanced.manager.util.isDebuggable
@Composable
fun BaseBundleDialog(
@@ -159,20 +161,18 @@ fun BaseBundleDialog(
)
}
+ val patchesClickable = LocalContext.current.isDebuggable && patchCount > 0
BundleListItem(
headlineText = stringResource(R.string.patches),
supportingText = if (patchCount == 0) stringResource(R.string.no_patches)
else stringResource(R.string.patches_available, patchCount),
- modifier = Modifier.clickable(enabled = patchCount > 0) {
- onPatchesClick()
- }
+ modifier = Modifier.clickable(enabled = patchesClickable, onClick = onPatchesClick)
) {
- if (patchCount > 0) {
+ if (patchesClickable)
Icon(
Icons.Outlined.ArrowRight,
stringResource(R.string.patches)
)
- }
}
version?.let {
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt
index 2ff2a555..a78cbb59 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt
@@ -70,17 +70,10 @@ fun BundlePatchesDialog(
item {
AnimatedVisibility(visible = informationCardVisible) {
NotificationCard(
- color = MaterialTheme.colorScheme.secondaryContainer,
icon = Icons.Outlined.Lightbulb,
- text = stringResource(R.string.tap_on_patches)
- ) {
- IconButton(onClick = { informationCardVisible = false }) {
- Icon(
- imageVector = Icons.Outlined.Close,
- contentDescription = stringResource(R.string.close),
- )
- }
- }
+ text = stringResource(R.string.tap_on_patches),
+ onDismiss = { informationCardVisible = false }
+ )
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
index e8fb4e4a..8f871afa 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
@@ -29,7 +29,6 @@ import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.util.toast
-import app.revanced.patcher.patch.options.types.*
import org.koin.compose.rememberKoinInject
// Composable functions do not support function references, so we have to use composable lambdas instead.
@@ -136,69 +135,69 @@ private fun StringOptionDialog(
)
}
-private val StringOption: OptionImpl = { option, value, setValue ->
- var showInputDialog by rememberSaveable { mutableStateOf(false) }
- fun showInputDialog() {
- showInputDialog = true
- }
-
- fun dismissInputDialog() {
- showInputDialog = false
- }
-
- if (showInputDialog) {
- StringOptionDialog(
- name = option.title,
- value = value as? String,
- onSubmit = {
- dismissInputDialog()
- setValue(it)
- },
- onDismissRequest = ::dismissInputDialog
- )
- }
-
- OptionListItem(
- option = option,
- onClick = ::showInputDialog
- ) {
- IconButton(onClick = ::showInputDialog) {
- Icon(
- Icons.Outlined.Edit,
- contentDescription = stringResource(R.string.string_option_icon_description)
- )
- }
- }
-}
-
-private val BooleanOption: OptionImpl = { option, value, setValue ->
- val current = (value as? Boolean) ?: false
-
- OptionListItem(
- option = option,
- onClick = { setValue(!current) }
- ) {
- Switch(checked = current, onCheckedChange = setValue)
- }
-}
-
-private val UnknownOption: OptionImpl = { option, _, _ ->
+private val unknownOption: OptionImpl = { option, _, _ ->
val context = LocalContext.current
OptionListItem(
option = option,
- onClick = { context.toast("Unknown type: ${option.type.name}") },
+ onClick = { context.toast("Unknown type: ${option.type}") },
trailingContent = {})
}
+private val optionImplementations = mapOf(
+ // These are the only two types that are currently used by the official patches
+ "Boolean" to { option, value, setValue ->
+ val current = (value as? Boolean) ?: false
+
+ OptionListItem(
+ option = option,
+ onClick = { setValue(!current) }
+ ) {
+ Switch(checked = current, onCheckedChange = setValue)
+ }
+ },
+ "String" to { option, value, setValue ->
+ var showInputDialog by rememberSaveable { mutableStateOf(false) }
+ fun showInputDialog() {
+ showInputDialog = true
+ }
+
+ fun dismissInputDialog() {
+ showInputDialog = false
+ }
+
+ if (showInputDialog) {
+ StringOptionDialog(
+ name = option.title,
+ value = value as? String,
+ onSubmit = {
+ dismissInputDialog()
+ setValue(it)
+ },
+ onDismissRequest = ::dismissInputDialog
+ )
+ }
+
+ OptionListItem(
+ option = option,
+ onClick = ::showInputDialog
+ ) {
+ IconButton(onClick = ::showInputDialog) {
+ Icon(
+ Icons.Outlined.Edit,
+ contentDescription = stringResource(R.string.string_option_icon_description)
+ )
+ }
+ }
+ }
+)
+
@Composable
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
val implementation = remember(option.type) {
- when (option.type) {
- // These are the only two types that are currently used by the official patches.
- StringPatchOption::class.java -> StringOption
- BooleanPatchOption::class.java -> BooleanOption
- else -> UnknownOption
- }
+ optionImplementations.getOrDefault(
+ option.type,
+ unknownOption
+ )
}
implementation(option, value, setValue)
diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt
index 8ed78775..5df102a1 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt
@@ -2,9 +2,7 @@ package app.revanced.manager.ui.component.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
-import androidx.compose.material3.ListItem
import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
@@ -37,10 +35,10 @@ fun BooleanItem(
onValueChange: (Boolean) -> Unit,
@StringRes headline: Int,
@StringRes description: Int
-) = ListItem(
+) = SettingsListItem(
modifier = Modifier.clickable { onValueChange(!value) },
- headlineContent = { Text(stringResource(headline)) },
- supportingContent = { Text(stringResource(description)) },
+ headlineContent = stringResource(headline),
+ supportingContent = stringResource(description),
trailingContent = {
Switch(
checked = value,
diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt
new file mode 100644
index 00000000..0a609e78
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt
@@ -0,0 +1,95 @@
+package app.revanced.manager.ui.component.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CalendarToday
+import androidx.compose.material.icons.outlined.Campaign
+import androidx.compose.material.icons.outlined.FileDownload
+import androidx.compose.material.icons.outlined.Sell
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.ui.component.Markdown
+
+@Composable
+fun Changelog(
+ markdown: String,
+ version: String,
+ downloadCount: String,
+ publishDate: String
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 0.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Campaign,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(32.dp)
+ )
+ Text(
+ version.removePrefix("v"),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Tag(
+ Icons.Outlined.Sell,
+ version
+ )
+ Tag(
+ Icons.Outlined.FileDownload,
+ downloadCount
+ )
+ Tag(
+ Icons.Outlined.CalendarToday,
+ publishDate
+ )
+ }
+ }
+ Markdown(
+ markdown,
+ )
+}
+
+@Composable
+private fun Tag(icon: ImageVector, text: String) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.outline,
+ )
+ Text(
+ text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt
new file mode 100644
index 00000000..2d40dda7
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt
@@ -0,0 +1,47 @@
+package app.revanced.manager.ui.component.settings
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ListItemColors
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.material3.ListItem
+
+@Composable
+fun SettingsListItem(
+ headlineContent: String,
+ modifier: Modifier = Modifier,
+ overlineContent: @Composable (() -> Unit)? = null,
+ supportingContent: String? = null,
+ leadingContent: @Composable (() -> Unit)? = null,
+ trailingContent: @Composable (() -> Unit)? = null,
+ colors: ListItemColors = ListItemDefaults.colors(),
+ tonalElevation: Dp = ListItemDefaults.Elevation,
+ shadowElevation: Dp = ListItemDefaults.Elevation,
+) = ListItem(
+ headlineContent = {
+ Text(
+ text = headlineContent,
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ modifier = modifier.then(Modifier.padding(horizontal = 8.dp)),
+ overlineContent = overlineContent,
+ supportingContent = {
+ if (supportingContent != null)
+ Text(
+ text = supportingContent,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ leadingContent = leadingContent,
+ trailingContent = trailingContent,
+ colors = colors,
+ tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt
index e737ed8c..a7712532 100644
--- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt
+++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt
@@ -11,22 +11,22 @@ import kotlinx.parcelize.RawValue
sealed interface Destination : Parcelable {
@Parcelize
- object Dashboard : Destination
+ data object Dashboard : Destination
@Parcelize
- data class ApplicationInfo(val installedApp: InstalledApp) : Destination
+ data class InstalledApplicationInfo(val installedApp: InstalledApp) : Destination
@Parcelize
- object AppSelector : Destination
+ data object AppSelector : Destination
@Parcelize
- object Settings : Destination
+ data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
@Parcelize
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
- data class PatchesSelector(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
+ data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt
new file mode 100644
index 00000000..32036c2a
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt
@@ -0,0 +1,19 @@
+package app.revanced.manager.ui.destination
+
+import android.os.Parcelable
+import app.revanced.manager.ui.model.SelectedApp
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PatchesSelection
+import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
+
+sealed interface SelectedAppInfoDestination : Parcelable {
+ @Parcelize
+ data object Main : SelectedAppInfoDestination
+
+ @Parcelize
+ data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchesSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
+
+ @Parcelize
+ data object VersionSelector: SelectedAppInfoDestination
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt
index ffdf20bc..5b6e59ee 100644
--- a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt
+++ b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt
@@ -27,15 +27,14 @@ sealed interface SettingsDestination : Parcelable {
object About : SettingsDestination
@Parcelize
- object UpdateProgress : SettingsDestination
+ data class Update(val downloadOnScreenEntry: Boolean) : SettingsDestination
@Parcelize
- object UpdateChangelog : SettingsDestination
+ object Changelogs : SettingsDestination
@Parcelize
object Contributors: SettingsDestination
@Parcelize
object Licenses: SettingsDestination
-
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt
new file mode 100644
index 00000000..fd048e4b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt
@@ -0,0 +1,82 @@
+package app.revanced.manager.ui.model
+
+import app.revanced.manager.domain.repository.PatchBundleRepository
+import app.revanced.manager.patcher.patch.PatchInfo
+import app.revanced.manager.util.PatchesSelection
+import app.revanced.manager.util.flatMapLatestAndCombine
+import kotlinx.coroutines.flow.map
+
+/**
+ * A data class that contains patch bundle metadata for use by UI code.
+ */
+data class BundleInfo(
+ val name: String,
+ val uid: Int,
+ val supported: List,
+ val unsupported: List,
+ val universal: List
+) {
+ val all = sequence {
+ yieldAll(supported)
+ yieldAll(unsupported)
+ yieldAll(universal)
+ }
+
+ val patchCount get() = supported.size + unsupported.size + universal.size
+
+ fun patchSequence(allowUnsupported: Boolean) = if (allowUnsupported) {
+ all
+ } else {
+ sequence {
+ yieldAll(supported)
+ yieldAll(universal)
+ }
+ }
+
+ companion object Extensions {
+ inline fun Iterable.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchesSelection = this.associate { bundle ->
+ val patches =
+ bundle.patchSequence(allowUnsupported)
+ .mapNotNullTo(mutableSetOf()) { patch ->
+ patch.name.takeIf {
+ condition(
+ bundle.uid,
+ patch
+ )
+ }
+ }
+
+ bundle.uid to patches
+ }
+
+ fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) =
+ sources.flatMapLatestAndCombine(
+ combiner = { it.filterNotNull() }
+ ) { source ->
+ // Regenerate bundle information whenever this source updates.
+ source.state.map { state ->
+ val bundle = state.patchBundleOrNull() ?: return@map null
+
+ val supported = mutableListOf()
+ val unsupported = mutableListOf()
+ val universal = mutableListOf()
+
+ bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
+ val targetList = when {
+ it.compatiblePackages == null -> universal
+ it.supportsVersion(
+ packageName,
+ version
+ ) -> supported
+
+ else -> unsupported
+ }
+
+ targetList.add(it)
+ }
+
+ BundleInfo(source.name, source.uid, supported, unsupported, universal)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt
index be71514b..8bd2cbaa 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt
@@ -1,5 +1,6 @@
package app.revanced.manager.ui.screen
+import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -65,7 +66,12 @@ fun DashboardScreen(
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val androidContext = LocalContext.current
- val pagerState = rememberPagerState()
+ val pagerState = rememberPagerState(
+ initialPage = DashboardPage.DASHBOARD.ordinal,
+ initialPageOffsetFraction = 0f
+ ) {
+ DashboardPage.values().size
+ }
val composableScope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) {
@@ -186,7 +192,6 @@ fun DashboardScreen(
}
HorizontalPager(
- pageCount = pages.size,
state = pagerState,
userScrollEnabled = true,
modifier = Modifier.fillMaxSize(),
@@ -199,6 +204,13 @@ fun DashboardScreen(
}
DashboardPage.BUNDLES -> {
+ BackHandler {
+ if (bundlesSelectable) vm.cancelSourceSelection() else composableScope.launch {
+ pagerState.animateScrollToPage(
+ DashboardPage.DASHBOARD.ordinal
+ )
+ }
+ }
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt
similarity index 77%
rename from app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt
rename to app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt
index 1cd3762b..fe29a9f3 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt
@@ -5,9 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -21,7 +19,6 @@ import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -32,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.pluralStringResource
@@ -40,19 +36,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
-import app.revanced.manager.ui.component.AppIcon
-import app.revanced.manager.ui.component.AppLabel
+import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
+import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.component.SegmentedButton
-import app.revanced.manager.ui.viewmodel.AppInfoViewModel
+import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
import app.revanced.manager.util.PatchesSelection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun AppInfoScreen(
+fun InstalledAppInfoScreen(
onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit,
onBackClick: () -> Unit,
- viewModel: AppInfoViewModel
+ viewModel: InstalledAppInfoViewModel
) {
SideEffect {
viewModel.onBackClick = onBackClick
@@ -80,27 +76,8 @@ fun AppInfoScreen(
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- AppIcon(
- viewModel.appInfo,
- contentDescription = null,
- modifier = Modifier
- .size(100.dp)
- .padding(bottom = 5.dp)
- )
-
- AppLabel(
- viewModel.appInfo,
- style = MaterialTheme.typography.titleLarge,
- defaultText = null
- )
-
- Text(viewModel.installedApp.version, style = MaterialTheme.typography.bodySmall)
+ AppInfo(viewModel.appInfo) {
+ Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
if (viewModel.installedApp.installType == InstallType.ROOT) {
Text(
@@ -166,38 +143,35 @@ fun AppInfoScreen(
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
- ListItem(
+ SettingsListItem(
modifier = Modifier.clickable { },
- headlineContent = { Text(stringResource(R.string.applied_patches)) },
- supportingContent = {
- Text(
+ headlineContent = stringResource(R.string.applied_patches),
+ supportingContent =
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
pluralStringResource(
id = R.plurals.applied_patches,
it,
it
)
- }
- )
- },
+ },
trailingContent = { Icon(Icons.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) }
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.package_name)) },
- supportingContent = { Text(viewModel.installedApp.currentPackageName) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.package_name),
+ supportingContent = viewModel.installedApp.currentPackageName
)
if (viewModel.installedApp.originalPackageName != viewModel.installedApp.currentPackageName) {
- ListItem(
- headlineContent = { Text(stringResource(R.string.original_package_name)) },
- supportingContent = { Text(viewModel.installedApp.originalPackageName) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.original_package_name),
+ supportingContent = viewModel.installedApp.originalPackageName
)
}
- ListItem(
- headlineContent = { Text(stringResource(R.string.install_type)) },
- supportingContent = { Text(stringResource(viewModel.installedApp.installType.stringResource)) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.install_type),
+ supportingContent = stringResource(viewModel.installedApp.installType.stringResource)
)
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt
index 82bd6f6b..c7535a6a 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt
@@ -2,10 +2,13 @@ package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -18,9 +21,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
+import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.LoadingIndicator
+import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
import org.koin.androidx.compose.getViewModel
@@ -31,43 +36,55 @@ fun InstalledAppsScreen(
) {
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = installedApps?.let { if (it.isEmpty()) Arrangement.Center else Arrangement.Top } ?: Arrangement.Center
- ) {
- installedApps?.let { installedApps ->
+ Column {
+ if (!Aapt.supportsDevice()) {
+ NotificationCard(
+ isWarning = true,
+ icon = Icons.Outlined.WarningAmber,
+ text = stringResource(
+ R.string.unsupported_architecture_warning
+ ),
+ )
+ }
- if (installedApps.isNotEmpty()) {
- items(
- installedApps,
- key = { it.currentPackageName }
- ) { installedApp ->
- viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo ->
- ListItem(
- modifier = Modifier.clickable { onAppClick(installedApp) },
- leadingContent = {
- AppIcon(
- packageInfo,
- contentDescription = null,
- Modifier.size(36.dp)
- )
- },
- headlineContent = { AppLabel(packageInfo, defaultText = null) },
- supportingContent = { Text(installedApp.currentPackageName) }
- )
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top
+ ) {
+ installedApps?.let { installedApps ->
+ if (installedApps.isNotEmpty()) {
+ items(
+ installedApps,
+ key = { it.currentPackageName }
+ ) { installedApp ->
+ viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo ->
+ ListItem(
+ modifier = Modifier.clickable { onAppClick(installedApp) },
+ leadingContent = {
+ AppIcon(
+ packageInfo,
+ contentDescription = null,
+ Modifier.size(36.dp)
+ )
+ },
+ headlineContent = { AppLabel(packageInfo, defaultText = null) },
+ supportingContent = { Text(installedApp.currentPackageName) }
+ )
+
+ }
+ }
+ } else {
+ item {
+ Text(
+ text = stringResource(R.string.no_patched_apps_found),
+ style = MaterialTheme.typography.titleLarge
+ )
}
}
- } else {
- item {
- Text(
- text = stringResource(R.string.no_patched_apps_found),
- style = MaterialTheme.typography.titleLarge
- )
- }
- }
- } ?: item { LoadingIndicator() }
+ } ?: item { LoadingIndicator() }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt
index c2a6d300..b72b944b 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt
@@ -13,7 +13,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
-import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material.icons.outlined.FileDownload
+import androidx.compose.material.icons.outlined.PostAdd
+import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
@@ -36,8 +38,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
-import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.patcher.worker.State
+import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ArrowButton
@@ -59,7 +61,6 @@ fun InstallerScreen(
val patcherState by vm.patcherState.observeAsState(null)
val steps by vm.progress.collectAsStateWithLifecycle()
val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } }
- var dropdownActive by rememberSaveable { mutableStateOf(false) }
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
if (showInstallPicker)
@@ -72,23 +73,40 @@ fun InstallerScreen(
topBar = {
AppTopBar(
title = stringResource(R.string.installer),
- onBackClick = onBackClick,
- actions = {
- IconButton(onClick = { dropdownActive = true }) {
- Icon(Icons.Outlined.MoreVert, stringResource(R.string.more))
- }
- DropdownMenu(
- expanded = dropdownActive,
- onDismissRequest = { dropdownActive = false }
- ) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.save_logs)) },
- onClick = { vm.exportLogs(context) },
- enabled = patcherState != null
- )
- }
- }
+ onBackClick = onBackClick
)
+ },
+ bottomBar = {
+ AnimatedVisibility(patcherState != null) {
+ BottomAppBar(
+ actions = {
+ if (canInstall) {
+ IconButton(onClick = { exportApkLauncher.launch("${vm.packageName}.apk") }) {
+ Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))
+ }
+ }
+ IconButton(onClick = { vm.exportLogs(context) }) {
+ Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs))
+ }
+ },
+ floatingActionButton = {
+ if (canInstall) {
+ ExtendedFloatingActionButton(
+ text = { Text(stringResource(vm.appButtonText)) },
+ icon = { Icon(Icons.Outlined.FileDownload, stringResource(id = R.string.install_app)) },
+ containerColor = BottomAppBarDefaults.bottomAppBarFabColor,
+ elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
+ onClick = {
+ if (vm.installedPackageName == null)
+ showInstallPicker = true
+ else
+ vm.open()
+ }
+ )
+ }
+ }
+ )
+ }
}
) { paddingValues ->
Column(
@@ -100,33 +118,6 @@ fun InstallerScreen(
steps.forEach {
InstallStep(it)
}
- Spacer(modifier = Modifier.weight(1f))
- Row(
- verticalAlignment = Alignment.Bottom,
- horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
- ) {
- Button(
- onClick = { exportApkLauncher.launch("${vm.packageName}.apk") },
- enabled = canInstall
- ) {
- Text(stringResource(R.string.export_app))
- }
-
- Button(
- onClick = {
- if (vm.installedPackageName == null)
- showInstallPicker = true
- else
- vm.open()
- },
- enabled = canInstall
- ) {
- Text(stringResource(vm.appButtonText))
- }
- }
}
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt
index 36d12f23..b91cef03 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt
@@ -14,17 +14,16 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.HelpOutline
-import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Restore
+import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
@@ -32,16 +31,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
+import androidx.compose.material3.SearchBar
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -61,7 +64,6 @@ import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.Countdown
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
-import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.BaseSelectionMode
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
@@ -73,18 +75,75 @@ import org.koin.compose.rememberKoinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PatchesSelectorScreen(
- onPatchClick: (PatchesSelection, Options) -> Unit,
+ onSave: (PatchesSelection?, Options) -> Unit,
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
- val pagerState = rememberPagerState()
- val composableScope = rememberCoroutineScope()
-
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList())
+ val pagerState = rememberPagerState(
+ initialPage = 0,
+ initialPageOffsetFraction = 0f
+ ) {
+ bundles.size
+ }
+ val composableScope = rememberCoroutineScope()
+ var search: String? by rememberSaveable {
+ mutableStateOf(null)
+ }
+ var showBottomSheet by rememberSaveable { mutableStateOf(false) }
+ val showPatchButton by remember {
+ derivedStateOf { vm.selectionIsValid(bundles) }
+ }
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ showBottomSheet = false
+ }
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.patches_selector_sheet_filter_title),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.patches_selector_sheet_filter_compat_title),
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(5.dp)
+ ) {
+ FilterChip(
+ selected = vm.filter and SHOW_SUPPORTED != 0,
+ onClick = { vm.toggleFlag(SHOW_SUPPORTED) },
+ label = { Text(stringResource(R.string.supported)) }
+ )
+
+ FilterChip(
+ selected = vm.filter and SHOW_UNIVERSAL != 0,
+ onClick = { vm.toggleFlag(SHOW_UNIVERSAL) },
+ label = { Text(stringResource(R.string.universal)) },
+ )
+
+ FilterChip(
+ selected = vm.filter and SHOW_UNSUPPORTED != 0,
+ onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) },
+ label = { Text(stringResource(R.string.unsupported)) },
+ )
+ }
+ }
+ }
+ }
if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog(
- appVersion = vm.input.selectedApp.version,
+ appVersion = vm.appVersion,
supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs
)
@@ -106,6 +165,112 @@ fun PatchesSelectorScreen(
)
}
+ fun LazyListScope.patchList(
+ uid: Int,
+ patches: List,
+ filterFlag: Int,
+ supported: Boolean,
+ header: (@Composable () -> Unit)? = null
+ ) {
+ if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) {
+ header?.let {
+ item {
+ it()
+ }
+ }
+
+ items(
+ items = patches,
+ key = { it.name }
+ ) { patch ->
+ PatchItem(
+ patch = patch,
+ onOptionsDialog = {
+ vm.optionsDialog = uid to patch
+ },
+ selected = supported && vm.isSelected(
+ uid,
+ patch
+ ),
+ onToggle = {
+ if (vm.selectionWarningEnabled) {
+ vm.pendingSelectionAction = {
+ vm.togglePatch(uid, patch)
+ }
+ } else {
+ vm.togglePatch(uid, patch)
+ }
+ },
+ supported = supported
+ )
+ }
+ }
+ }
+
+ search?.let { query ->
+ SearchBar(
+ query = query,
+ onQueryChange = { new ->
+ search = new
+ },
+ onSearch = {},
+ active = true,
+ onActiveChange = { new ->
+ if (new) return@SearchBar
+ search = null
+ },
+ placeholder = {
+ Text(stringResource(R.string.search_patches))
+ },
+ leadingIcon = {
+ IconButton(onClick = { search = null }) {
+ Icon(
+ Icons.Default.ArrowBack,
+ stringResource(R.string.back)
+ )
+ }
+ }
+ ) {
+ val bundle = bundles[pagerState.currentPage]
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ fun List.searched() = filter {
+ it.name.contains(query, true)
+ }
+
+ patchList(
+ uid = bundle.uid,
+ patches = bundle.supported.searched(),
+ filterFlag = SHOW_SUPPORTED,
+ supported = true
+ )
+ patchList(
+ uid = bundle.uid,
+ patches = bundle.universal.searched(),
+ filterFlag = SHOW_UNIVERSAL,
+ supported = true
+ ) {
+ ListHeader(
+ title = stringResource(R.string.universal_patches),
+ )
+ }
+
+ if (!vm.allowExperimental) return@LazyColumn
+ patchList(
+ uid = bundle.uid,
+ patches = bundle.unsupported.searched(),
+ filterFlag = SHOW_UNSUPPORTED,
+ supported = true
+ ) {
+ ListHeader(
+ title = stringResource(R.string.unsupported_patches),
+ onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
+ )
+ }
+ }
+ }
+ }
+
+
Scaffold(
topBar = {
AppTopBar(
@@ -115,50 +280,28 @@ fun PatchesSelectorScreen(
IconButton(onClick = vm::reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
-
- var dropdownActive by rememberSaveable {
- mutableStateOf(false)
+ IconButton(onClick = { showBottomSheet = true }) {
+ Icon(Icons.Outlined.FilterList, stringResource(R.string.more))
}
- // This part should probably be changed
- IconButton(onClick = { dropdownActive = true }) {
- Icon(Icons.Outlined.MoreVert, stringResource(R.string.more))
- DropdownMenu(
- expanded = dropdownActive,
- onDismissRequest = { dropdownActive = false }
- ) {
- DropdownMenuItem(
- text = {
- val id =
- if (vm.baseSelectionMode == BaseSelectionMode.DEFAULT)
- R.string.menu_opt_selection_mode_previous else R.string.menu_opt_selection_mode_default
-
- Text(stringResource(id))
- },
- onClick = {
- dropdownActive = false
- vm.switchBaseSelectionMode()
- },
- enabled = vm.hasPreviousSelection
- )
+ IconButton(
+ onClick = {
+ search = ""
}
- }
- IconButton(onClick = { }) {
+ ) {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
}
}
)
},
floatingActionButton = {
+ if (!showPatchButton) return@Scaffold
+
ExtendedFloatingActionButton(
- text = { Text(stringResource(R.string.patch)) },
- icon = { Icon(Icons.Default.Build, null) },
+ text = { Text(stringResource(R.string.save)) },
+ icon = { Icon(Icons.Outlined.Save, null) },
onClick = {
// TODO: only allow this if all required options have been set.
- composableScope.launch {
- val selection = vm.getSelection()
- vm.saveSelection(selection).join()
- onPatchClick(selection, vm.getOptions())
- }
+ onSave(vm.getCustomSelection(), vm.getOptions())
}
)
}
@@ -192,113 +335,40 @@ fun PatchesSelectorScreen(
}
HorizontalPager(
- pageCount = bundles.size,
state = pagerState,
userScrollEnabled = true,
pageContent = { index ->
val bundle = bundles[index]
- Column {
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 10.dp, vertical = 2.dp),
- horizontalArrangement = Arrangement.spacedBy(5.dp)
+ LazyColumn(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ patchList(
+ uid = bundle.uid,
+ patches = bundle.supported,
+ filterFlag = SHOW_SUPPORTED,
+ supported = true
+ )
+ patchList(
+ uid = bundle.uid,
+ patches = bundle.universal,
+ filterFlag = SHOW_UNIVERSAL,
+ supported = true
) {
- FilterChip(
- selected = vm.filter and SHOW_SUPPORTED != 0 && bundle.supported.isNotEmpty(),
- onClick = { vm.toggleFlag(SHOW_SUPPORTED) },
- label = { Text(stringResource(R.string.supported)) },
- enabled = bundle.supported.isNotEmpty()
- )
-
- FilterChip(
- selected = vm.filter and SHOW_UNIVERSAL != 0 && bundle.universal.isNotEmpty(),
- onClick = { vm.toggleFlag(SHOW_UNIVERSAL) },
- label = { Text(stringResource(R.string.universal)) },
- enabled = bundle.universal.isNotEmpty()
- )
-
- FilterChip(
- selected = vm.filter and SHOW_UNSUPPORTED != 0 && bundle.unsupported.isNotEmpty(),
- onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) },
- label = { Text(stringResource(R.string.unsupported)) },
- enabled = bundle.unsupported.isNotEmpty()
+ ListHeader(
+ title = stringResource(R.string.universal_patches),
)
}
-
- val allowExperimental by vm.allowExperimental.getAsState()
-
- LazyColumn(
- modifier = Modifier.fillMaxSize()
+ patchList(
+ uid = bundle.uid,
+ patches = bundle.unsupported,
+ filterFlag = SHOW_UNSUPPORTED,
+ supported = vm.allowExperimental
) {
- fun LazyListScope.patchList(
- patches: List,
- filterFlag: Int,
- supported: Boolean,
- header: (@Composable () -> Unit)? = null
- ) {
- if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) {
- header?.let {
- item {
- it()
- }
- }
-
- items(
- items = patches,
- key = { it.name }
- ) { patch ->
- PatchItem(
- patch = patch,
- onOptionsDialog = {
- vm.optionsDialog = bundle.uid to patch
- },
- selected = supported && vm.isSelected(
- bundle.uid,
- patch
- ),
- onToggle = {
- if (vm.selectionWarningEnabled) {
- vm.pendingSelectionAction = {
- vm.togglePatch(bundle.uid, patch)
- }
- } else {
- vm.togglePatch(bundle.uid, patch)
- }
- },
- supported = supported
- )
- }
- }
- }
-
- patchList(
- patches = bundle.supported,
- filterFlag = SHOW_SUPPORTED,
- supported = true
+ ListHeader(
+ title = stringResource(R.string.unsupported_patches),
+ onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
)
- patchList(
- patches = bundle.universal,
- filterFlag = SHOW_UNIVERSAL,
- supported = true
- ) {
- ListHeader(
- title = stringResource(R.string.universal_patches),
- onHelpClick = { }
- )
- }
- patchList(
- patches = bundle.unsupported,
- filterFlag = SHOW_UNSUPPORTED,
- supported = allowExperimental
- ) {
- ListHeader(
- title = stringResource(R.string.unsupported_patches),
- onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
- )
- }
}
}
}
@@ -400,7 +470,7 @@ fun PatchItem(
leadingContent = {
Checkbox(
checked = selected,
- onCheckedChange = null,
+ onCheckedChange = { onToggle() },
enabled = supported
)
},
@@ -501,7 +571,7 @@ fun OptionsDialog(
items(patch.options, key = { it.key }) { option ->
val key = option.key
val value =
- if (values == null || !values.contains(key)) option.defaultValue else values[key]
+ if (values == null || !values.contains(key)) option.default else values[key]
OptionItem(option = option, value = value, setValue = { set(key, it) })
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt
new file mode 100644
index 00000000..a2978f67
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt
@@ -0,0 +1,229 @@
+package app.revanced.manager.ui.screen
+
+import android.content.pm.PackageInfo
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowRight
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.AppInfo
+import app.revanced.manager.ui.component.AppTopBar
+import app.revanced.manager.ui.destination.SelectedAppInfoDestination
+import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
+import app.revanced.manager.ui.model.SelectedApp
+import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
+import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PatchesSelection
+import app.revanced.manager.util.toast
+import dev.olshevski.navigation.reimagined.AnimatedNavHost
+import dev.olshevski.navigation.reimagined.NavBackHandler
+import dev.olshevski.navigation.reimagined.navigate
+import dev.olshevski.navigation.reimagined.pop
+import dev.olshevski.navigation.reimagined.rememberNavController
+import org.koin.androidx.compose.getViewModel
+import org.koin.core.parameter.parametersOf
+
+@Composable
+fun SelectedAppInfoScreen(
+ onPatchClick: (SelectedApp, PatchesSelection, Options) -> Unit,
+ onBackClick: () -> Unit,
+ vm: SelectedAppInfoViewModel
+) {
+ val context = LocalContext.current
+
+ val packageName = vm.selectedApp.packageName
+ val version = vm.selectedApp.version
+ val bundles by remember(packageName, version) {
+ vm.bundlesRepo.bundleInfoFlow(packageName, version)
+ }.collectAsStateWithLifecycle(initialValue = emptyList())
+
+ val allowExperimental by vm.prefs.allowExperimental.getAsState()
+ val patches by remember {
+ derivedStateOf {
+ vm.getPatches(bundles, allowExperimental)
+ }
+ }
+ val selectedPatchCount by remember {
+ derivedStateOf {
+ patches.values.sumOf { it.size }
+ }
+ }
+ val availablePatchCount by remember {
+ derivedStateOf {
+ bundles.sumOf { it.patchCount }
+ }
+ }
+
+ val navController =
+ rememberNavController(startDestination = SelectedAppInfoDestination.Main)
+
+ NavBackHandler(controller = navController)
+
+ AnimatedNavHost(controller = navController) { destination ->
+ when (destination) {
+ is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen(
+ onPatchClick = patchClick@{
+ if (selectedPatchCount == 0) {
+ context.toast(context.getString(R.string.no_patches_selected))
+
+ return@patchClick
+ }
+ onPatchClick(
+ vm.selectedApp,
+ patches,
+ vm.getOptionsFiltered(bundles)
+ )
+ },
+ onPatchSelectorClick = {
+ navController.navigate(
+ SelectedAppInfoDestination.PatchesSelector(
+ vm.selectedApp,
+ vm.getCustomPatches(
+ bundles,
+ allowExperimental
+ ),
+ vm.options
+ )
+ )
+ },
+ onVersionSelectorClick = {
+ navController.navigate(SelectedAppInfoDestination.VersionSelector)
+ },
+ onBackClick = onBackClick,
+ availablePatchCount = availablePatchCount,
+ selectedPatchCount = selectedPatchCount,
+ packageName = packageName,
+ version = version,
+ packageInfo = vm.selectedAppInfo,
+ )
+
+ is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen(
+ onBackClick = navController::pop,
+ onAppClick = {
+ vm.selectedApp = it
+ navController.pop()
+ },
+ viewModel = getViewModel { parametersOf(packageName) }
+ )
+
+ is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
+ onSave = { patches, options ->
+ vm.updateConfiguration(patches, options, bundles)
+ navController.pop()
+ },
+ onBackClick = navController::pop,
+ vm = getViewModel {
+ parametersOf(
+ PatchesSelectorViewModel.Params(
+ destination.app,
+ destination.currentSelection,
+ destination.options,
+ )
+ )
+ }
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SelectedAppInfoScreen(
+ onPatchClick: () -> Unit,
+ onPatchSelectorClick: () -> Unit,
+ onVersionSelectorClick: () -> Unit,
+ onBackClick: () -> Unit,
+ availablePatchCount: Int,
+ selectedPatchCount: Int,
+ packageName: String,
+ version: String,
+ packageInfo: PackageInfo?,
+) {
+ Scaffold(
+ topBar = {
+ AppTopBar(
+ title = stringResource(R.string.app_info),
+ onBackClick = onBackClick
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ AppInfo(packageInfo, placeholderLabel = packageName) {
+ Text(
+ stringResource(R.string.selected_app_meta, version, availablePatchCount),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ PageItem(R.string.patch, stringResource(R.string.patch_item_description), onPatchClick)
+
+ Text(
+ stringResource(R.string.advanced),
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
+ )
+
+ PageItem(
+ R.string.patch_selector_item,
+ stringResource(R.string.patch_selector_item_description, selectedPatchCount),
+ onPatchSelectorClick
+ )
+ PageItem(
+ R.string.version_selector_item,
+ stringResource(R.string.version_selector_item_description, version),
+ onVersionSelectorClick
+ )
+ }
+ }
+}
+
+@Composable
+private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
+ ListItem(
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .padding(start = 8.dp),
+ headlineContent = {
+ Text(
+ stringResource(title),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ supportingContent = {
+ Text(
+ description,
+ color = MaterialTheme.colorScheme.outline,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ trailingContent = {
+ Icon(Icons.Outlined.ArrowRight, null)
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt
index 3aaa7d44..e26602f4 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt
@@ -7,18 +7,11 @@ import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
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.filled.BatteryAlert
@@ -29,32 +22,39 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
+import app.revanced.manager.ui.component.NotificationCard
+import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.destination.SettingsDestination
import app.revanced.manager.ui.screen.settings.*
-import app.revanced.manager.ui.screen.settings.update.ManagerUpdateChangelog
-import app.revanced.manager.ui.screen.settings.update.UpdateProgressScreen
+import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen
+import app.revanced.manager.ui.screen.settings.update.UpdateScreen
import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen
import app.revanced.manager.ui.viewmodel.SettingsViewModel
import dev.olshevski.navigation.reimagined.*
import org.koin.androidx.compose.getViewModel
+import org.koin.core.parameter.parametersOf
+import org.koin.androidx.compose.getViewModel as getComposeViewModel
@SuppressLint("BatteryLife")
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackClick: () -> Unit,
+ startDestination: SettingsDestination,
viewModel: SettingsViewModel = getViewModel()
) {
- val navController =
- rememberNavController(startDestination = SettingsDestination.Settings)
+ val navController = rememberNavController(startDestination)
+
+ val backClick: () -> Unit = {
+ if (navController.backstack.entries.size == 1)
+ onBackClick()
+ else navController.pop()
+ }
val context = LocalContext.current
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -98,50 +98,54 @@ fun SettingsScreen(
controller = navController
) { destination ->
when (destination) {
-
is SettingsDestination.General -> GeneralSettingsScreen(
- onBackClick = { navController.pop() },
+ onBackClick = backClick,
viewModel = viewModel
)
is SettingsDestination.Advanced -> AdvancedSettingsScreen(
- onBackClick = { navController.pop() }
+ onBackClick = backClick
)
is SettingsDestination.Updates -> UpdatesSettingsScreen(
- onBackClick = { navController.pop() },
- onChangelogClick = { navController.navigate(SettingsDestination.UpdateChangelog) },
- onUpdateClick = { navController.navigate(SettingsDestination.UpdateProgress) }
+ onBackClick = backClick,
+ onChangelogClick = { navController.navigate(SettingsDestination.Changelogs) },
+ onUpdateClick = { navController.navigate(SettingsDestination.Update(false)) }
)
is SettingsDestination.Downloads -> DownloadsSettingsScreen(
- onBackClick = { navController.pop() }
+ onBackClick = backClick
)
is SettingsDestination.ImportExport -> ImportExportSettingsScreen(
- onBackClick = { navController.pop() }
+ onBackClick = backClick
)
is SettingsDestination.About -> AboutSettingsScreen(
- onBackClick = { navController.pop() },
+ onBackClick = backClick,
onContributorsClick = { navController.navigate(SettingsDestination.Contributors) },
onLicensesClick = { navController.navigate(SettingsDestination.Licenses) }
)
- is SettingsDestination.UpdateProgress -> UpdateProgressScreen(
- onBackClick = { navController.pop() },
+ is SettingsDestination.Update -> UpdateScreen(
+ onBackClick = backClick,
+ vm = getComposeViewModel {
+ parametersOf(
+ destination.downloadOnScreenEntry
+ )
+ }
)
- is SettingsDestination.UpdateChangelog -> ManagerUpdateChangelog(
- onBackClick = { navController.pop() },
+ is SettingsDestination.Changelogs -> ChangelogsScreen(
+ onBackClick = backClick,
)
is SettingsDestination.Contributors -> ContributorScreen(
- onBackClick = { navController.pop() },
+ onBackClick = backClick,
)
is SettingsDestination.Licenses -> LicensesScreen(
- onBackClick = { navController.pop() },
+ onBackClick = backClick,
)
is SettingsDestination.Settings -> {
@@ -149,7 +153,7 @@ fun SettingsScreen(
topBar = {
AppTopBar(
title = stringResource(R.string.settings),
- onBackClick = onBackClick,
+ onBackClick = backClick,
)
}
) { paddingValues ->
@@ -157,61 +161,27 @@ fun SettingsScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ .verticalScroll(rememberScrollState())
) {
AnimatedVisibility(visible = showBatteryButton) {
- Card(
- onClick = {
+ NotificationCard(
+ isWarning = true,
+ icon = Icons.Default.BatteryAlert,
+ text = stringResource(R.string.battery_optimization_notification),
+ primaryAction = {
context.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
})
showBatteryButton =
!pm.isIgnoringBatteryOptimizations(context.packageName)
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- .clip(RoundedCornerShape(24.dp))
- .background(MaterialTheme.colorScheme.tertiaryContainer),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Icon(
- imageVector = Icons.Default.BatteryAlert,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onTertiaryContainer,
- modifier = Modifier.size(24.dp)
- )
- Text(
- text = stringResource(R.string.battery_optimization_notification),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onTertiaryContainer
- )
}
- }
+ )
}
settingsSections.forEach { (titleDescIcon, destination) ->
- ListItem(
+ SettingsListItem(
modifier = Modifier.clickable { navController.navigate(destination) },
- headlineContent = {
- Text(
- stringResource(titleDescIcon.first),
- style = MaterialTheme.typography.titleLarge
- )
- },
- supportingContent = {
- Text(
- stringResource(titleDescIcon.second),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
- },
+ headlineContent = stringResource(titleDescIcon.first),
+ supportingContent = stringResource(titleDescIcon.second),
leadingContent = { Icon(titleDescIcon.third, null) }
)
}
@@ -220,4 +190,4 @@ fun SettingsScreen(
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt
index 341c14c9..63908faa 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt
@@ -1,9 +1,18 @@
package app.revanced.manager.ui.screen.settings
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
-import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -11,25 +20,30 @@ import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MailOutline
-import androidx.compose.material3.*
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.BuildConfig
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
-import app.revanced.manager.ui.destination.SettingsDestination
+import app.revanced.manager.ui.component.settings.SettingsListItem
+import app.revanced.manager.util.isDebuggable
import app.revanced.manager.util.openUrl
import com.google.accompanist.drawablepainter.rememberDrawablePainter
-import dev.olshevski.navigation.reimagined.NavController
-import dev.olshevski.navigation.reimagined.navigate
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AboutSettingsScreen(
onBackClick: () -> Unit,
@@ -37,7 +51,10 @@ fun AboutSettingsScreen(
onLicensesClick: () -> Unit,
) {
val context = LocalContext.current
- val icon = painterResource(R.drawable.ic_logo_ring)
+ // painterResource() is broken on release builds for some reason.
+ val icon = rememberDrawablePainter(drawable = remember {
+ AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring)
+ })
val filledButton = listOf(
Triple(Icons.Outlined.FavoriteBorder, stringResource(R.string.donate)) {
@@ -57,17 +74,25 @@ fun AboutSettingsScreen(
}),
)
- val listItems = listOf(
- Triple(stringResource(R.string.submit_feedback), stringResource(R.string.submit_feedback_description),
+ val listItems = listOfNotNull(
+ Triple(stringResource(R.string.submit_feedback),
+ stringResource(R.string.submit_feedback_description),
third = {
context.openUrl("https://github.com/ReVanced/revanced-manager/issues/new/choose")
}),
- Triple(stringResource(R.string.contributors), stringResource(R.string.contributors_description),
- third = onContributorsClick),
- Triple(stringResource(R.string.developer_options), stringResource(R.string.developer_options_description),
- third = { /*TODO*/ }),
- Triple(stringResource(R.string.opensource_licenses), stringResource(R.string.opensource_licenses_description),
- third = onLicensesClick)
+ Triple(
+ stringResource(R.string.contributors),
+ stringResource(R.string.contributors_description),
+ third = onContributorsClick
+ ),
+ Triple(stringResource(R.string.developer_options),
+ stringResource(R.string.developer_options_description),
+ third = { /*TODO*/ }).takeIf { context.isDebuggable },
+ Triple(
+ stringResource(R.string.opensource_licenses),
+ stringResource(R.string.opensource_licenses_description),
+ third = onLicensesClick
+ )
)
Scaffold(
@@ -82,96 +107,87 @@ fun AboutSettingsScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
- .verticalScroll(rememberScrollState())
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ Image(
+ modifier = Modifier.padding(top = 16.dp),
+ painter = icon,
+ contentDescription = null
+ )
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
- Image(painter = icon, contentDescription = null)
- Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge)
- Text( text = stringResource(R.string.version) + " " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")", style = MaterialTheme.typography.bodyMedium)
- Row(
- modifier = Modifier.padding(top = 12.dp)
- ) {
- filledButton.forEach { (icon, text, onClick) ->
- FilledTonalButton(
- onClick = onClick,
- modifier = Modifier.padding(end = 8.dp)
+ Text(
+ stringResource(R.string.app_name),
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Text(
+ text = stringResource(R.string.version) + " " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ FlowRow(
+ maxItemsInEachRow = 2,
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
+ ) {
+ filledButton.forEach { (icon, text, onClick) ->
+ FilledTonalButton(
+ onClick = onClick
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- icon,
- contentDescription = null,
- modifier = Modifier
- .size(28.dp)
- .padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- Text(
- text,
- style = MaterialTheme.typography.labelLarge,
- )
- }
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text,
+ style = MaterialTheme.typography.labelLarge
+ )
}
}
}
-
- Row(
- modifier = Modifier.padding(top = 12.dp)
- ) {
- outlinedButton.forEach { (icon, text, onClick) ->
- Button(
- onClick = onClick,
- modifier = Modifier.padding(end = 8.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = Color.Transparent,
- contentColor = MaterialTheme.colorScheme.onSecondaryContainer
- ),
- border = ButtonDefaults.outlinedButtonBorder
+ outlinedButton.forEach { (icon, text, onClick) ->
+ OutlinedButton(
+ onClick = onClick
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- icon,
- contentDescription = null,
- modifier = Modifier
- .size(28.dp)
- .padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- Text(
- text,
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.primary
- )
- }
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
}
}
}
}
- Box(
- modifier = Modifier
- .padding(vertical = 8.dp, horizontal = 16.dp)
- .border(
- width = 1.dp,
- color = MaterialTheme.colorScheme.outlineVariant,
- shape = MaterialTheme.shapes.medium
- )
- .padding(16.dp)
+ OutlinedCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
- Column {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Text(
text = stringResource(R.string.about_revanced_manager),
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(bottom = 8.dp),
+ style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.revanced_manager_description),
@@ -179,18 +195,17 @@ fun AboutSettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
-
}
-
- listItems.forEach { (title, description, onClick) ->
- ListItem(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp)
- .clickable { onClick() },
- headlineContent = { Text(title, style = MaterialTheme.typography.titleLarge) },
- supportingContent = { Text(description, style = MaterialTheme.typography.bodyMedium,color = MaterialTheme.colorScheme.outline) }
- )
+ Column {
+ listItems.forEach { (title, description, onClick) ->
+ SettingsListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() },
+ headlineContent = title,
+ supportingContent = description
+ )
+ }
}
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt
index 4af8e908..f1dc35b1 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.outlined.Http
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -36,6 +35,7 @@ import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
+import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel
import org.koin.androidx.compose.getViewModel
@@ -70,7 +70,7 @@ fun AdvancedSettingsScreen(
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
- val apiUrl by vm.apiUrl.getAsState()
+ val apiUrl by vm.prefs.api.getAsState()
var showApiUrlDialog by rememberSaveable { mutableStateOf(false) }
if (showApiUrlDialog) {
@@ -79,9 +79,9 @@ fun AdvancedSettingsScreen(
it?.let(vm::setApiUrl)
}
}
- ListItem(
- headlineContent = { Text(stringResource(R.string.api_url)) },
- supportingContent = { Text(apiUrl) },
+ SettingsListItem(
+ headlineContent = stringResource(R.string.api_url),
+ supportingContent = apiUrl,
modifier = Modifier.clickable {
showApiUrlDialog = true
}
@@ -89,42 +89,48 @@ fun AdvancedSettingsScreen(
GroupHeader(stringResource(R.string.patcher))
BooleanItem(
- preference = vm.allowExperimental,
+ preference = vm.prefs.allowExperimental,
coroutineScope = vm.viewModelScope,
headline = R.string.experimental_patches,
description = R.string.experimental_patches_description
)
+ BooleanItem(
+ preference = vm.prefs.multithreadingDexFileWriter,
+ coroutineScope = vm.viewModelScope,
+ headline = R.string.multithreaded_dex_file_writer,
+ description = R.string.multithreaded_dex_file_writer_description,
+ )
GroupHeader(stringResource(R.string.patch_bundles_section))
- ListItem(
- headlineContent = { Text(stringResource(R.string.patch_bundles_redownload)) },
+ SettingsListItem(
+ headlineContent = stringResource(R.string.patch_bundles_redownload),
modifier = Modifier.clickable {
vm.redownloadBundles()
}
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.patch_bundles_reset)) },
+ SettingsListItem(
+ headlineContent = stringResource(R.string.patch_bundles_reset),
modifier = Modifier.clickable {
vm.resetBundles()
}
)
GroupHeader(stringResource(R.string.device))
- ListItem(
- headlineContent = { Text(stringResource(R.string.device_model)) },
- supportingContent = { Text(Build.MODEL) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.device_model),
+ supportingContent = Build.MODEL
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.device_android_version)) },
- supportingContent = { Text(Build.VERSION.RELEASE) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.device_android_version),
+ supportingContent = Build.VERSION.RELEASE
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.device_architectures)) },
- supportingContent = { Text(Build.SUPPORTED_ABIS.joinToString(", ")) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.device_architectures),
+ supportingContent = Build.SUPPORTED_ABIS.joinToString(", ")
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.device_memory_limit)) },
- supportingContent = { Text(memoryLimit) }
+ SettingsListItem(
+ headlineContent = stringResource(R.string.device_memory_limit),
+ supportingContent = memoryLimit
)
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt
index 313eec24..e5fa5742 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt
@@ -1,35 +1,39 @@
package app.revanced.manager.ui.screen.settings
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.ArrowDropDown
-import androidx.compose.material.icons.outlined.ArrowDropUp
-import androidx.compose.material3.*
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-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.layout.ContentScale
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.coerceAtMost
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedContributor
import app.revanced.manager.ui.component.AppTopBar
-import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.ContributorViewModel
import coil.compose.AsyncImage
import org.koin.androidx.compose.getViewModel
-
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContributorScreen(
@@ -45,92 +49,148 @@ fun ContributorScreen(
)
},
) { paddingValues ->
- Column(
+ LazyColumn(
modifier = Modifier
.fillMaxHeight()
.padding(paddingValues)
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
+ .fillMaxWidth(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = if (repositories.isNullOrEmpty()) Arrangement.Center else Arrangement.spacedBy(
+ 24.dp
+ )
) {
- if(repositories.isEmpty()) {
- LoadingIndicator()
- }
- repositories.forEach {
- ExpandableListCard(
- title = it.name,
- contributors = it.contributors
- )
- }
+ repositories?.let { repositories ->
+ if (repositories.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(id = R.string.no_contributors_found),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ } else {
+ items(
+ items = repositories,
+ key = { it.name }
+ ) {
+ ContributorsCard(
+ title = it.name,
+ contributors = it.contributors
+ )
+ }
+ }
+ } ?: item { LoadingIndicator() }
}
}
}
-@OptIn(ExperimentalLayoutApi::class)
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
-fun ExpandableListCard(
+fun ContributorsCard(
title: String,
- contributors: List
+ contributors: List,
+ itemsPerPage: Int = 12,
+ numberOfRows: Int = 2
) {
- var expanded by remember { mutableStateOf(false) }
+ val itemsPerRow = (itemsPerPage / numberOfRows)
+
+ // Create a list of contributors grouped by itemsPerPage
+ val contributorsByPage = remember(itemsPerPage, contributors) {
+ contributors.chunked(itemsPerPage)
+ }
+ val pagerState = rememberPagerState { contributorsByPage.size }
+
Card(
- shape = RoundedCornerShape(30.dp),
- elevation = CardDefaults.outlinedCardElevation(),
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp)
.border(
- width = 2.dp,
- color = MaterialTheme.colorScheme.outline,
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = MaterialTheme.shapes.medium
),
- colors = CardDefaults.outlinedCardColors(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
- Column() {
- Row() {
- ListItem(
- headlineContent = {
- Text(
- text = processHeadlineText(title),
- style = MaterialTheme.typography.titleMedium
- )
- },
- trailingContent = {
- if (contributors.isNotEmpty()) {
- ArrowButton(
- expanded = expanded,
- onClick = { expanded = !expanded }
- )
- }
- },
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = processHeadlineText(title),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium)
+ )
+ Text(
+ text = "(${(pagerState.currentPage + 1)}/${pagerState.pageCount})",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold)
)
}
- if (expanded) {
- FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentHeight()
- .padding(8.dp),
- ) {
- contributors.forEach {
- AsyncImage(
- model = it.avatarUrl,
- contentDescription = it.avatarUrl,
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .padding(16.dp)
- .size(45.dp)
- .clip(CircleShape)
- )
+ HorizontalPager(
+ state = pagerState,
+ userScrollEnabled = true,
+ modifier = Modifier.fillMaxSize(),
+ ) { page ->
+ BoxWithConstraints {
+ val spaceBetween = 16.dp
+ val maxWidth = this.maxWidth
+ val itemSize = (maxWidth - (itemsPerRow - 1) * spaceBetween) / itemsPerRow
+ val itemSpacing = (maxWidth - itemSize * 6) / (itemsPerRow - 1)
+ FlowRow(
+ maxItemsInEachRow = itemsPerRow,
+ horizontalArrangement = Arrangement.spacedBy(itemSpacing),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ contributorsByPage[page].forEach {
+ if (itemSize > 100.dp) {
+ Row(
+ modifier = Modifier.width(itemSize - 1.dp), // we delete 1.dp to account for not-so divisible numbers
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ AsyncImage(
+ model = it.avatarUrl,
+ contentDescription = it.avatarUrl,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size((itemSize / 3).coerceAtMost(40.dp))
+ .clip(CircleShape)
+ )
+ Text(
+ text = it.username,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ } else {
+ Box(
+ modifier = Modifier.width(itemSize - 1.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ AsyncImage(
+ model = it.avatarUrl,
+ contentDescription = it.avatarUrl,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(size = (itemSize - 1.dp).coerceAtMost(50.dp)) // we delete 1.dp to account for not-so divisible numbers
+ .clip(CircleShape)
+ )
+ }
+ }
+ }
}
}
}
}
}
}
+
fun processHeadlineText(repositoryName: String): String {
- return "Revanced " + repositoryName.replace("revanced/revanced-", "")
+ return "ReVanced " + repositoryName.replace("revanced/revanced-", "")
.replace("-", " ")
- .split(" ")
- .map { if (it.length > 3) it else it.uppercase() }
- .joinToString(" ")
+ .split(" ").joinToString(" ") { if (it.length > 3) it else it.uppercase() }
.replaceFirstChar { it.uppercase() }
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt
index f13a5dfd..881c420b 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt
@@ -8,12 +8,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -23,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
+import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
import org.koin.androidx.compose.getViewModel
@@ -66,12 +66,20 @@ fun DownloadsSettingsScreen(
GroupHeader(stringResource(R.string.downloaded_apps))
- downloadedApps.forEach {
- ListItem(
- modifier = Modifier.clickable { viewModel.toggleItem(it) },
- headlineContent = { Text(it.packageName) },
- supportingContent = { Text(it.version) },
- tonalElevation = if (viewModel.selection.contains(it)) 8.dp else 0.dp
+ downloadedApps.forEach { app ->
+ val selected = app in viewModel.selection
+
+ SettingsListItem(
+ modifier = Modifier.clickable { viewModel.toggleItem(app) },
+ headlineContent = app.packageName,
+ leadingContent = (@Composable {
+ Checkbox(
+ checked = selected,
+ onCheckedChange = { viewModel.toggleItem(app) }
+ )
+ }).takeIf { viewModel.selection.isNotEmpty() },
+ supportingContent = app.version,
+ tonalElevation = if (selected) 8.dp else 0.dp
)
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt
index ed25e7b2..1224bbe3 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt
@@ -23,6 +23,7 @@ import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.SettingsViewModel
import org.koin.compose.koinInject
+import app.revanced.manager.ui.component.settings.SettingsListItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -58,12 +59,12 @@ fun GeneralSettingsScreen(
GroupHeader(stringResource(R.string.appearance))
val theme by prefs.theme.getAsState()
- ListItem(
+ SettingsListItem(
modifier = Modifier.clickable { showThemePicker = true },
- headlineContent = { Text(stringResource(R.string.theme)) },
- supportingContent = { Text(stringResource(R.string.theme_description)) },
+ headlineContent = stringResource(R.string.theme),
+ supportingContent = stringResource(R.string.theme_description),
trailingContent = {
- Button(
+ FilledTonalButton(
onClick = {
showThemePicker = true
}
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt
index 63fae1bf..627c174d 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt
@@ -6,7 +6,10 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -14,6 +17,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -35,6 +39,7 @@ import app.revanced.manager.ui.component.bundle.BundleSelector
import app.revanced.manager.util.toast
import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel
+import app.revanced.manager.ui.component.settings.SettingsListItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,8 +58,10 @@ fun ImportExportSettingsScreen(
it?.let(vm::exportKeystore)
}
+ val patchBundles by vm.patchBundles.collectAsStateWithLifecycle(initialValue = emptyList())
+ val packagesWithOptions by vm.packagesWithOptions.collectAsStateWithLifecycle(initialValue = emptySet())
+
vm.selectionAction?.let { action ->
- val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
val launcher = rememberLauncherForActivityResult(action.activityContract) { uri ->
if (uri == null) {
vm.clearSelectionAction()
@@ -64,7 +71,7 @@ fun ImportExportSettingsScreen(
}
if (vm.selectedBundle == null) {
- BundleSelector(sources) {
+ BundleSelector(patchBundles) {
if (it == null) {
vm.clearSelectionAction()
} else {
@@ -137,21 +144,120 @@ fun ImportExportSettingsScreen(
headline = R.string.backup_patches_selection,
description = R.string.backup_patches_selection_description
)
+ // TODO: allow resetting selection for specific bundle or package name.
GroupItem(
onClick = vm::resetSelection,
headline = R.string.clear_patches_selection,
description = R.string.clear_patches_selection_description
)
+
+ var showPackageSelector by rememberSaveable {
+ mutableStateOf(false)
+ }
+ var showBundleSelector by rememberSaveable {
+ mutableStateOf(false)
+ }
+
+ if (showPackageSelector)
+ PackageSelector(packages = packagesWithOptions) { selected ->
+ selected?.let(vm::clearOptionsForPackage)
+
+ showPackageSelector = false
+ }
+
+ if (showBundleSelector)
+ BundleSelector(bundles = patchBundles) { bundle ->
+ bundle?.let(vm::clearOptionsForBundle)
+
+ showBundleSelector = false
+ }
+
+ GroupHeader(stringResource(R.string.patch_options))
+ // TODO: patch options import/export.
+ GroupItem(
+ onClick = { showPackageSelector = true },
+ headline = R.string.patch_options_clear_package,
+ description = R.string.patch_options_clear_package_description
+ )
+ if (patchBundles.size > 1)
+ GroupItem(
+ onClick = { showBundleSelector = true },
+ headline = R.string.patch_options_clear_bundle,
+ description = R.string.patch_options_clear_bundle_description,
+ )
+ GroupItem(
+ onClick = vm::resetOptions,
+ headline = R.string.patch_options_clear_all,
+ description = R.string.patch_options_clear_all_description,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PackageSelector(packages: Set, onFinish: (String?) -> Unit) {
+ val context = LocalContext.current
+
+ val noPackages = packages.isEmpty()
+
+ LaunchedEffect(noPackages) {
+ if (noPackages) {
+ context.toast("No packages available.")
+ onFinish(null)
+ }
+ }
+
+ if (noPackages) return
+
+ ModalBottomSheet(
+ onDismissRequest = { onFinish(null) }
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .height(48.dp)
+ .fillMaxWidth()
+ ) {
+ Text(
+ text = "Select package",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ packages.forEach {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .height(48.dp)
+ .fillMaxWidth()
+ .clickable {
+ onFinish(it)
+ }
+ ) {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
}
}
}
@Composable
private fun GroupItem(onClick: () -> Unit, @StringRes headline: Int, @StringRes description: Int) =
- ListItem(
+ SettingsListItem(
modifier = Modifier.clickable { onClick() },
- headlineContent = { Text(stringResource(headline)) },
- supportingContent = { Text(stringResource(description)) }
+ headlineContent = stringResource(headline),
+ supportingContent = stringResource(description)
)
@Composable
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt
new file mode 100644
index 00000000..a1ecd92b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt
@@ -0,0 +1,96 @@
+package app.revanced.manager.ui.screen.settings.update
+
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.AppTopBar
+import app.revanced.manager.ui.component.LoadingIndicator
+import app.revanced.manager.ui.component.settings.Changelog
+import app.revanced.manager.ui.viewmodel.ChangelogsViewModel
+import app.revanced.manager.util.formatNumber
+import app.revanced.manager.util.relativeTime
+import org.koin.androidx.compose.getViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChangelogsScreen(
+ onBackClick: () -> Unit,
+ vm: ChangelogsViewModel = getViewModel()
+) {
+ val changelogs = vm.changelogs
+
+ Scaffold(
+ topBar = {
+ AppTopBar(
+ title = stringResource(R.string.changelog),
+ onBackClick = onBackClick
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(paddingValues)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = if (changelogs.isNullOrEmpty()) Arrangement.Center else Arrangement.Top
+ ) {
+ if (changelogs == null) {
+ item {
+ LoadingIndicator()
+ }
+ } else if (changelogs.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(id = R.string.no_changelogs_found),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ } else {
+ val lastChangelog = changelogs.last()
+ items(
+ changelogs,
+ key = { it.version }
+ ) { changelog ->
+ ChangelogItem(changelog, lastChangelog)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ChangelogItem(
+ changelog: ChangelogsViewModel.Changelog,
+ lastChangelog: ChangelogsViewModel.Changelog
+) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Changelog(
+ markdown = changelog.body.replace("`", ""),
+ version = changelog.version,
+ downloadCount = changelog.downloadCount.formatNumber(),
+ publishDate = changelog.publishDate.relativeTime(LocalContext.current)
+ )
+ if (changelog != lastChangelog) {
+ Divider(
+ modifier = Modifier.padding(top = 32.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt
deleted file mode 100644
index 5ba085e6..00000000
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-package app.revanced.manager.ui.screen.settings.update
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Campaign
-import androidx.compose.material.icons.outlined.FileDownload
-import androidx.compose.material.icons.outlined.Sell
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import app.revanced.manager.R
-import app.revanced.manager.ui.component.AppTopBar
-import app.revanced.manager.ui.component.Markdown
-import app.revanced.manager.ui.viewmodel.ManagerUpdateChangelogViewModel
-import org.koin.androidx.compose.getViewModel
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ManagerUpdateChangelog(
- onBackClick: () -> Unit,
- vm: ManagerUpdateChangelogViewModel = getViewModel()
-) {
- Scaffold(
- topBar = {
- AppTopBar(
- title = stringResource(R.string.changelog),
- onBackClick = onBackClick
- )
- }
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(start = 16.dp, end = 16.dp, top = 16.dp)
- .verticalScroll(rememberScrollState())
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 4.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = Icons.Outlined.Campaign,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier
- .size(32.dp)
- )
- Text(
- vm.changelog.version.removePrefix("v"),
- style = MaterialTheme.typography.headlineMedium,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- Row(
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 16.dp),
- ) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = Icons.Outlined.Sell,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- Text(
- vm.changelog.version,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline,
- )
- }
- }
- Markdown(
- vm.changelogHtml,
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt
deleted file mode 100644
index b1a5f152..00000000
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package app.revanced.manager.ui.screen.settings.update
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Stable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import app.revanced.manager.R
-import app.revanced.manager.ui.component.AppTopBar
-import app.revanced.manager.ui.viewmodel.UpdateProgressViewModel
-import org.koin.androidx.compose.getViewModel
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-@Stable
-fun UpdateProgressScreen(
- onBackClick: () -> Unit,
- vm: UpdateProgressViewModel = getViewModel()
-) {
- Scaffold(
- topBar = {
- AppTopBar(
- title = stringResource(R.string.updates),
- onBackClick = onBackClick
- )
- }
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(vertical = 16.dp, horizontal = 24.dp)
- .verticalScroll(rememberScrollState()),
- ) {
- Text(
- text = if (vm.isInstalling) stringResource(R.string.installing_manager_update) else stringResource(
- R.string.downloading_manager_update
- ), style = MaterialTheme.typography.headlineMedium
- )
- LinearProgressIndicator(
- progress = vm.downloadProgress,
- modifier = Modifier
- .padding(vertical = 16.dp)
- .fillMaxWidth()
- )
- Text(
- text = if (!vm.isInstalling) "${vm.downloadedSize.div(1000000)} MB / ${
- vm.totalSize.div(
- 1000000
- )
- } MB (${vm.downloadProgress.times(100).toInt()}%)" else stringResource(R.string.installing_message),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline,
- modifier = Modifier.align(Alignment.CenterHorizontally),
- textAlign = TextAlign.Center
- )
- Text(
- text = "This update adds many functionality and fixes many issues in Manager. New experiment toggles are also added, they can be found in Settings > Advanced. Please submit some feedback in Settings > About > Submit issues or feedback. Thank you, everyone!",
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(vertical = 32.dp),
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Spacer(modifier = Modifier.weight(1f))
- Row(
- verticalAlignment = Alignment.Bottom,
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth()
- ) {
- TextButton(
- onClick = onBackClick,
- ) {
- Text(text = stringResource(R.string.cancel))
- }
- Button(onClick = vm::installUpdate, enabled = vm.finished) {
- Text(text = stringResource(R.string.update))
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt
new file mode 100644
index 00000000..29ca28fd
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt
@@ -0,0 +1,239 @@
+package app.revanced.manager.ui.screen.settings.update
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Update
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+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.unit.dp
+import app.revanced.manager.BuildConfig
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.AppTopBar
+import app.revanced.manager.ui.component.settings.Changelog
+import app.revanced.manager.ui.viewmodel.UpdateViewModel
+import app.revanced.manager.ui.viewmodel.UpdateViewModel.Changelog
+import app.revanced.manager.ui.viewmodel.UpdateViewModel.State
+import app.revanced.manager.util.formatNumber
+import app.revanced.manager.util.relativeTime
+import com.gigamole.composefadingedges.content.FadingEdgesContentType
+import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig
+import com.gigamole.composefadingedges.fill.FadingEdgesFillType
+import com.gigamole.composefadingedges.verticalFadingEdges
+import org.koin.androidx.compose.getViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+@Stable
+fun UpdateScreen(
+ onBackClick: () -> Unit,
+ vm: UpdateViewModel = getViewModel()
+) {
+ Scaffold(
+ topBar = {
+ AppTopBar(
+ title = stringResource(R.string.updates),
+ onBackClick = onBackClick
+ )
+ }
+ ) { paddingValues ->
+ AnimatedVisibility(visible = vm.showInternetCheckDialog) {
+ MeteredDownloadConfirmationDialog(
+ onDismiss = { vm.showInternetCheckDialog = false },
+ onDownloadAnyways = { vm.downloadUpdate(true) }
+ )
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(vertical = 16.dp, horizontal = 24.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(32.dp)
+ ) {
+ Header(
+ vm.state,
+ vm.changelog,
+ DownloadData(vm.downloadProgress, vm.downloadedSize, vm.totalSize)
+ )
+ vm.changelog?.let { changelog ->
+ Divider()
+ Changelog(changelog)
+ } ?: Spacer(modifier = Modifier.weight(1f))
+ Buttons(vm.state, vm::downloadUpdate, vm::installUpdate, onBackClick)
+ }
+ }
+}
+
+@Composable
+private fun MeteredDownloadConfirmationDialog(
+ onDismiss: () -> Unit,
+ onDownloadAnyways: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ dismissButton = {
+ TextButton(onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onDismiss()
+ onDownloadAnyways()
+ }
+ ) {
+ Text(stringResource(R.string.download))
+ }
+ },
+ title = { Text(stringResource(R.string.download_update_confirmation)) },
+ icon = { Icon(Icons.Outlined.Update, null) },
+ text = { Text(stringResource(R.string.download_confirmation_metered)) }
+ )
+}
+
+@Composable
+private fun Header(state: State, changelog: Changelog?, downloadData: DownloadData) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ text = stringResource(state.title),
+ style = MaterialTheme.typography.headlineMedium
+ )
+ if (state == State.CAN_DOWNLOAD) {
+ Column {
+ Text(
+ text = stringResource(
+ id = R.string.current_version,
+ BuildConfig.VERSION_NAME
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ changelog?.let { changelog ->
+ Text(
+ text = stringResource(
+ id = R.string.new_version,
+ changelog.version.replace("v", "")
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ } else if (state == State.DOWNLOADING) {
+ LinearProgressIndicator(
+ progress = downloadData.downloadProgress,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Text(
+ text =
+ "${downloadData.downloadedSize.div(1000000)} MB / ${
+ downloadData.totalSize.div(
+ 1000000
+ )
+ } MB (${
+ downloadData.downloadProgress.times(
+ 100
+ ).toInt()
+ }%)",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.Changelog(changelog: Changelog) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .verticalScroll(scrollState)
+ .verticalFadingEdges(
+ fillType = FadingEdgesFillType.FadeColor(
+ color = MaterialTheme.colorScheme.background,
+ fillStops = Triple(0F, 0.55F, 1F),
+ secondStopAlpha = 1F
+ ),
+ contentType = FadingEdgesContentType.Dynamic.Scroll(
+ state = scrollState,
+ scrollConfig = FadingEdgesScrollConfig.Dynamic(
+ animationSpec = spring(),
+ isLerpByDifferenceForPartialContent = true,
+ scrollFactor = 1.25F
+ )
+ ),
+ length = 350.dp
+ )
+ ) {
+ Changelog(
+ markdown = changelog.body.replace("`", ""),
+ version = changelog.version,
+ downloadCount = changelog.downloadCount.formatNumber(),
+ publishDate = changelog.publishDate.relativeTime(LocalContext.current)
+ )
+ }
+}
+
+@Composable
+private fun Buttons(
+ state: State,
+ onDownloadClick: () -> Unit,
+ onInstallClick: () -> Unit,
+ onBackClick: () -> Unit
+) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ if (state.showCancel) {
+ TextButton(
+ onClick = onBackClick,
+ ) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ if (state == State.CAN_DOWNLOAD) {
+ Button(onClick = onDownloadClick) {
+ Text(text = stringResource(R.string.update))
+ }
+ } else if (state == State.CAN_INSTALL) {
+ Button(
+ onClick = onInstallClick
+ ) {
+ Text(text = stringResource(R.string.install_app))
+ }
+ }
+ }
+}
+
+data class DownloadData(
+ val downloadProgress: Float,
+ val downloadedSize: Long,
+ val totalSize: Long
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt
index 18b9fc97..88e094e0 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt
@@ -1,33 +1,24 @@
package app.revanced.manager.ui.screen.settings.update
-import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.filled.Update
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
+import app.revanced.manager.ui.component.NotificationCard
+import app.revanced.manager.ui.component.settings.SettingsListItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -69,59 +60,22 @@ fun UpdatesSettingsScreen(
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
- UpdateNotification(
- onClick = onUpdateClick
+ NotificationCard(
+ text = stringResource(R.string.update_notification),
+ icon = Icons.Default.Update,
+ primaryAction = onUpdateClick
)
listItems.forEach { (title, description, onClick) ->
- ListItem(
+ SettingsListItem(
modifier = Modifier
.fillMaxWidth()
- .padding(8.dp)
+ .padding(horizontal = 8.dp)
.clickable { onClick() },
- headlineContent = {
- Text(
- title,
- style = MaterialTheme.typography.titleLarge
- )
- },
- supportingContent = {
- Text(
- description,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
- }
+ headlineContent = title,
+ supportingContent = description
)
}
}
}
-}
-
-@Composable
-fun UpdateNotification(
- onClick: () -> Unit
-) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- .clip(RoundedCornerShape(24.dp))
- .background(MaterialTheme.colorScheme.secondaryContainer)
- .clickable { onClick() },
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Icon(imageVector = Icons.Default.Update, contentDescription = null)
- Text(
- text = stringResource(R.string.update_notification),
- style = MaterialTheme.typography.bodyMedium
- )
- }
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt
index 6d7d79b8..9efed1dd 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt
@@ -12,17 +12,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AdvancedSettingsViewModel(
- prefs: PreferencesManager,
+ val prefs: PreferencesManager,
private val app: Application,
private val patchBundleRepository: PatchBundleRepository
) : ViewModel() {
- val apiUrl = prefs.api
- val allowExperimental = prefs.allowExperimental
-
fun setApiUrl(value: String) = viewModelScope.launch(Dispatchers.Default) {
- if (value == apiUrl.get()) return@launch
+ if (value == prefs.api.get()) return@launch
- apiUrl.update(value)
+ prefs.api.update(value)
patchBundleRepository.reloadApiBundles()
}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt
new file mode 100644
index 00000000..61466ed2
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt
@@ -0,0 +1,44 @@
+package app.revanced.manager.ui.viewmodel
+
+import android.app.Application
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.revanced.manager.R
+import app.revanced.manager.network.api.ReVancedAPI
+import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
+import app.revanced.manager.network.utils.getOrNull
+import app.revanced.manager.util.APK_MIMETYPE
+import app.revanced.manager.util.uiSafe
+import kotlinx.coroutines.launch
+
+class ChangelogsViewModel(
+ private val api: ReVancedAPI,
+ private val app: Application,
+) : ViewModel() {
+ var changelogs: List? by mutableStateOf(null)
+
+ init {
+ viewModelScope.launch {
+ uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") {
+ changelogs = api.getReleases("revanced-manager").getOrNull().orEmpty().map { release ->
+ Changelog(
+ release.metadata.tag,
+ release.findAssetByType(APK_MIMETYPE).downloadCount,
+ release.metadata.publishedAt,
+ release.metadata.body
+ )
+ }
+ }
+ }
+ }
+
+ data class Changelog(
+ val version: String,
+ val downloadCount: Int,
+ val publishDate: String,
+ val body: String,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt
index 3230c011..72fbfd7d 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt
@@ -1,6 +1,9 @@
package app.revanced.manager.ui.viewmodel
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.network.api.ReVancedAPI
@@ -11,13 +14,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ContributorViewModel(private val reVancedAPI: ReVancedAPI) : ViewModel() {
- val repositories = mutableStateListOf()
+ var repositories: List? by mutableStateOf(null)
+ private set
init {
viewModelScope.launch {
- withContext(Dispatchers.IO) { reVancedAPI.getContributors().getOrNull() }?.let(
- repositories::addAll
- )
+ repositories = withContext(Dispatchers.IO) {
+ reVancedAPI.getContributors().getOrNull()
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt
index 85f36fc7..ee107163 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt
@@ -16,6 +16,7 @@ import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.bundles.PatchBundleSource
+import app.revanced.manager.domain.repository.PatchOptionsRepository
import app.revanced.manager.util.JSON_MIMETYPE
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
@@ -38,10 +39,11 @@ class ImportExportViewModel(
private val app: Application,
private val keystoreManager: KeystoreManager,
private val selectionRepository: PatchSelectionRepository,
+ private val optionsRepository: PatchOptionsRepository,
patchBundleRepository: PatchBundleRepository
) : ViewModel() {
private val contentResolver = app.contentResolver
- val sources = patchBundleRepository.sources
+ val patchBundles = patchBundleRepository.sources
var selectedBundle by mutableStateOf(null)
private set
var selectionAction by mutableStateOf(null)
@@ -49,6 +51,20 @@ class ImportExportViewModel(
private var keystoreImportPath by mutableStateOf(null)
val showCredentialsDialog by derivedStateOf { keystoreImportPath != null }
+ val packagesWithOptions = optionsRepository.getPackagesWithSavedOptions()
+
+ fun clearOptionsForPackage(packageName: String) = viewModelScope.launch {
+ optionsRepository.clearOptionsForPackage(packageName)
+ }
+
+ fun clearOptionsForBundle(patchBundle: PatchBundleSource) = viewModelScope.launch {
+ optionsRepository.clearOptionsForPatchBundle(patchBundle.uid)
+ }
+
+ fun resetOptions() = viewModelScope.launch {
+ optionsRepository.reset()
+ }
+
fun startKeystoreImport(content: Uri) = viewModelScope.launch {
val path = withContext(Dispatchers.IO) {
File.createTempFile("signing", "ks", app.cacheDir).toPath().also {
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt
similarity index 89%
rename from app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt
rename to app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt
index 7cf31727..90fbf264 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt
@@ -11,6 +11,7 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
@@ -30,7 +31,7 @@ import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
-class AppInfoViewModel(
+class InstalledAppInfoViewModel(
val installedApp: InstalledApp
) : ViewModel(), KoinComponent {
private val app: Application by inject()
@@ -83,8 +84,10 @@ class AppInfoViewModel(
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
UninstallService.APP_UNINSTALL_ACTION -> {
- val extraStatus = intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999)
- val extraStatusMessage = intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
+ val extraStatus =
+ intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999)
+ val extraStatusMessage =
+ intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
if (extraStatus == PackageInstaller.STATUS_SUCCESS) {
viewModelScope.launch {
@@ -113,9 +116,11 @@ class AppInfoViewModel(
}
}
- app.registerReceiver(
+ ContextCompat.registerReceiver(
+ app,
uninstallBroadcastReceiver,
- IntentFilter(UninstallService.APP_UNINSTALL_ACTION)
+ IntentFilter(UninstallService.APP_UNINSTALL_ACTION),
+ ContextCompat.RECEIVER_NOT_EXPORTED
)
}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt
index 1a78078a..177a7e5f 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
@@ -23,7 +24,6 @@ import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
-import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.worker.PatcherProgressManager
@@ -56,7 +56,6 @@ import java.util.logging.LogRecord
class InstallerViewModel(
private val input: Destination.Installer
) : ViewModel(), KoinComponent {
- private val keystoreManager: KeystoreManager by inject()
private val app: Application by inject()
private val fs: Filesystem by inject()
private val pm: PM by inject()
@@ -71,8 +70,6 @@ class InstallerViewModel(
}
private val outputFile = tempDir.resolve("output.apk")
- private val signedFile = tempDir.resolve("signed.apk")
- private var hasSigned = false
private var inputFile: File? = null
private var installedApp: InstalledApp? = null
@@ -97,11 +94,13 @@ class InstallerViewModel(
val (selectedApp, patches, options) = input
- _progress = MutableStateFlow(PatcherProgressManager.generateSteps(
- app,
- patches.flatMap { (_, selected) -> selected },
- selectedApp
- ).toImmutableList())
+ _progress = MutableStateFlow(
+ PatcherProgressManager.generateSteps(
+ app,
+ patches.flatMap { (_, selected) -> selected },
+ selectedApp
+ ).toImmutableList()
+ )
patcherWorkerId =
workerRepository.launchExpedited(
@@ -140,7 +139,7 @@ class InstallerViewModel(
installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch {
- installedAppRepository.add(
+ installedAppRepository.addOrUpdate(
installedPackageName!!,
packageName,
input.selectedApp.version,
@@ -160,10 +159,10 @@ class InstallerViewModel(
}
init {
- app.registerReceiver(installBroadcastReceiver, IntentFilter().apply {
+ ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
- })
+ }, ContextCompat.RECEIVER_NOT_EXPORTED)
}
fun exportLogs(context: Context) {
@@ -186,62 +185,47 @@ class InstallerViewModel(
is SelectedApp.Local -> {
if (selectedApp.shouldDelete) selectedApp.file.delete()
}
+
+ is SelectedApp.Installed -> {
+ try {
+ installedApp?.let {
+ if (it.installType == InstallType.ROOT) {
+ rootInstaller.mount(packageName)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(tag, "Failed to mount", e)
+ app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage()))
+ }
+ }
+
else -> {}
}
tempDir.deleteRecursively()
-
- try {
- if (input.selectedApp is SelectedApp.Installed) {
- installedApp?.let {
- if (it.installType == InstallType.ROOT) {
- rootInstaller.mount(packageName)
- }
- }
- }
- } catch (e: Exception) {
- Log.e(tag, "Failed to mount", e)
- app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage()))
- }
- }
-
- private suspend fun signApk(): Boolean {
- if (!hasSigned) {
- try {
- withContext(Dispatchers.Default) {
- keystoreManager.sign(outputFile, signedFile)
- }
- } catch (e: Exception) {
- Log.e(tag, "Got exception while signing", e)
- app.toast(app.getString(R.string.sign_fail, e::class.simpleName))
- return false
- }
- }
-
- return true
}
fun export(uri: Uri?) = viewModelScope.launch {
uri?.let {
- if (signApk()) {
- withContext(Dispatchers.IO) {
- app.contentResolver.openOutputStream(it)
- .use { stream -> Files.copy(signedFile.toPath(), stream) }
- }
- app.toast(app.getString(R.string.export_app_success))
+ withContext(Dispatchers.IO) {
+ app.contentResolver.openOutputStream(it)
+ .use { stream -> Files.copy(outputFile.toPath(), stream) }
}
+ app.toast(app.getString(R.string.save_apk_success))
}
}
fun install(installType: InstallType) = viewModelScope.launch {
isInstalling = true
try {
- if (!signApk()) return@launch
-
when (installType) {
- InstallType.DEFAULT -> { pm.installApp(listOf(signedFile)) }
+ InstallType.DEFAULT -> {
+ pm.installApp(listOf(outputFile))
+ }
- InstallType.ROOT -> { installAsRoot() }
+ InstallType.ROOT -> {
+ installAsRoot()
+ }
}
} finally {
@@ -254,7 +238,7 @@ class InstallerViewModel(
private suspend fun installAsRoot() {
try {
val label = with(pm) {
- getPackageInfo(signedFile)?.label()
+ getPackageInfo(outputFile)?.label()
?: throw Exception("Failed to load application info")
}
@@ -270,7 +254,7 @@ class InstallerViewModel(
installedApp?.let { installedAppRepository.delete(it) }
- installedAppRepository.add(
+ installedAppRepository.addOrUpdate(
packageName,
packageName,
input.selectedApp.version,
@@ -286,7 +270,8 @@ class InstallerViewModel(
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
try {
rootInstaller.uninstall(packageName)
- } catch (_: Exception) { }
+ } catch (_: Exception) {
+ }
}
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt
index 5592d3e7..0fcfedfd 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt
@@ -1,23 +1,77 @@
package app.revanced.manager.ui.viewmodel
+import android.app.Application
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Build
+import android.util.Base64
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import app.revanced.manager.R
+import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull
+import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
-import kotlinx.coroutines.Dispatchers
+import app.revanced.manager.domain.repository.PatchSelectionRepository
+import app.revanced.manager.domain.repository.SerializedSelection
+import app.revanced.manager.network.api.ReVancedAPI
+import app.revanced.manager.network.utils.getOrThrow
+import app.revanced.manager.ui.theme.Theme
+import app.revanced.manager.util.tag
+import app.revanced.manager.util.toast
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
class MainViewModel(
private val patchBundleRepository: PatchBundleRepository,
+ private val patchSelectionRepository: PatchSelectionRepository,
+ private val keystoreManager: KeystoreManager,
+ private val reVancedAPI: ReVancedAPI,
+ private val app: Application,
+ private val networkInfo: NetworkInfo,
val prefs: PreferencesManager
) : ViewModel() {
+ var updatedManagerVersion: String? by mutableStateOf(null)
+ private set
+
+ init {
+ viewModelScope.launch { checkForManagerUpdates() }
+ }
+
+ fun dismissUpdateDialog() {
+ updatedManagerVersion = null
+ }
+
+ private suspend fun checkForManagerUpdates() {
+ if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return
+
+ try {
+ reVancedAPI.getLatestRelease("revanced-manager").getOrThrow().let { release ->
+ updatedManagerVersion = release.metadata.tag.takeIf { it != Build.VERSION.RELEASE }
+ }
+ } catch (e: Exception) {
+ app.toast(app.getString(R.string.failed_to_check_updates))
+ Log.e(tag, "Failed to check for updates", e)
+ }
+ }
fun applyAutoUpdatePrefs(manager: Boolean, patches: Boolean) = viewModelScope.launch {
- prefs.showAutoUpdatesDialog.update(false)
+ prefs.firstLaunch.update(false)
prefs.managerAutoUpdates.update(manager)
+
+ if (manager) checkForManagerUpdates()
+
if (patches) {
with(patchBundleRepository) {
sources
@@ -30,4 +84,97 @@ class MainViewModel(
}
}
}
-}
\ No newline at end of file
+
+ fun importLegacySettings(componentActivity: ComponentActivity) {
+ if (!prefs.firstLaunch.getBlocking()) return
+
+ try {
+ val launcher = componentActivity.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result: ActivityResult ->
+ if (result.resultCode == ComponentActivity.RESULT_OK) {
+ result.data?.getStringExtra("data")?.let {
+ applyLegacySettings(it)
+ } ?: app.toast(app.getString(R.string.legacy_import_failed))
+ } else {
+ app.toast(app.getString(R.string.legacy_import_failed))
+ }
+ }
+
+ val intent = Intent().apply {
+ setClassName(
+ "app.revanced.manager.flutter",
+ "app.revanced.manager.flutter.ExportSettingsActivity"
+ )
+ }
+
+ launcher.launch(intent)
+ } catch (e: Exception) {
+ if (e !is ActivityNotFoundException) {
+ app.toast(app.getString(R.string.legacy_import_failed))
+ Log.e(tag, "Failed to launch legacy import activity: $e")
+ }
+ }
+ }
+
+ private fun applyLegacySettings(data: String) = viewModelScope.launch {
+ val json = Json { ignoreUnknownKeys = true }
+ val settings = json.decodeFromString(data)
+
+ settings.themeMode?.let { theme ->
+ val themeMap = mapOf(
+ 0 to Theme.SYSTEM,
+ 1 to Theme.LIGHT,
+ 2 to Theme.DARK
+ )
+ prefs.theme.update(themeMap[theme] ?: Theme.SYSTEM)
+ }
+ settings.useDynamicTheme?.let { dynamicColor ->
+ prefs.dynamicColor.update(dynamicColor)
+ }
+ settings.apiUrl?.let { api ->
+ prefs.api.update(api.removeSuffix("/"))
+ }
+ settings.experimentalPatchesEnabled?.let { allowExperimental ->
+ prefs.allowExperimental.update(allowExperimental)
+ }
+ settings.patchesAutoUpdate?.let { autoUpdate ->
+ with(patchBundleRepository) {
+ sources
+ .first()
+ .find { it.uid == 0 }
+ ?.asRemoteOrNull
+ ?.setAutoUpdate(autoUpdate)
+
+ updateCheck()
+ }
+ }
+ settings.patchesChangeEnabled?.let { disableSelectionWarning ->
+ prefs.disableSelectionWarning.update(disableSelectionWarning)
+ }
+ settings.keystore?.let { keystore ->
+ val keystoreBytes = Base64.decode(keystore, Base64.DEFAULT)
+ keystoreManager.import(
+ "ReVanced",
+ settings.keystorePassword,
+ keystoreBytes.inputStream()
+ )
+ }
+ settings.patches?.let { selection ->
+ patchSelectionRepository.import(0, selection)
+ }
+ }
+
+ @Serializable
+ private data class LegacySettings(
+ val keystorePassword: String,
+ val themeMode: Int? = null,
+ val useDynamicTheme: Boolean? = null,
+ val apiUrl: String? = null,
+ val experimentalPatchesEnabled: Boolean? = null,
+ val patchesAutoUpdate: Boolean? = null,
+ val patchesChangeEnabled: Boolean? = null,
+ val keystore: String? = null,
+ val patches: SerializedSelection? = null,
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt
deleted file mode 100644
index 02d187ea..00000000
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package app.revanced.manager.ui.viewmodel
-
-import android.app.Application
-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 app.revanced.manager.R
-import app.revanced.manager.network.api.ReVancedAPI
-import app.revanced.manager.network.utils.getOrThrow
-import app.revanced.manager.util.uiSafe
-import kotlinx.coroutines.launch
-import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
-import org.intellij.markdown.html.HtmlGenerator
-import org.intellij.markdown.parser.MarkdownParser
-
-class ManagerUpdateChangelogViewModel(
- private val api: ReVancedAPI,
- private val app: Application,
-) : ViewModel() {
- private val markdownFlavour = GFMFlavourDescriptor()
- private val markdownParser = MarkdownParser(flavour = markdownFlavour)
-
- var changelog by mutableStateOf(
- Changelog(
- "...",
- app.getString(R.string.changelog_loading),
- )
- )
- private set
- val changelogHtml by derivedStateOf {
- val markdown = changelog.body
- val parsedTree = markdownParser.buildMarkdownTreeFromString(markdown)
- HtmlGenerator(markdown, parsedTree, markdownFlavour).generateHtml()
- }
-
- init {
- viewModelScope.launch {
- uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") {
- changelog = api.getRelease("revanced-manager").getOrThrow().let {
- Changelog(it.metadata.tag, it.metadata.body)
- }
- }
- }
- }
-
- data class Changelog(
- val version: String,
- val body: String,
- )
-}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt
index 036bc613..f3df1c4d 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt
@@ -2,8 +2,8 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.Stable
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
@@ -17,69 +17,45 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
-import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
-import app.revanced.manager.ui.destination.Destination
+import app.revanced.manager.ui.model.BundleInfo
+import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
+import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
+import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
-import app.revanced.manager.util.flatMapLatestAndCombine
+import app.revanced.manager.util.saver.Nullable
+import app.revanced.manager.util.saver.nullableSaver
+import app.revanced.manager.util.saver.persistentMapSaver
+import app.revanced.manager.util.saver.persistentSetSaver
import app.revanced.manager.util.saver.snapshotStateMapSaver
import app.revanced.manager.util.toast
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
+import kotlinx.collections.immutable.*
+import java.util.Optional
@Stable
@OptIn(SavedStateHandleSaveableApi::class)
-class PatchesSelectorViewModel(
- val input: Destination.PatchesSelector
-) : ViewModel(), KoinComponent {
+class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent {
private val app: Application = get()
- private val selectionRepository: PatchSelectionRepository = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
- private val packageName = input.selectedApp.packageName
+ private val packageName = input.app.packageName
+ val appVersion = input.app.version
var pendingSelectionAction by mutableStateOf<(() -> Unit)?>(null)
var selectionWarningEnabled by mutableStateOf(true)
private set
- val allowExperimental = get().allowExperimental
- val bundlesFlow = get().sources.flatMapLatestAndCombine(
- combiner = { it.filterNotNull() }
- ) { source ->
- // Regenerate bundle information whenever this source updates.
- source.state.map { state ->
- val bundle = state.patchBundleOrNull() ?: return@map null
-
- val supported = mutableListOf()
- val unsupported = mutableListOf()
- val universal = mutableListOf()
-
- bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
- val targetList = when {
- it.compatiblePackages == null -> universal
- it.supportsVersion(
- input.selectedApp.packageName,
- input.selectedApp.version
- ) -> supported
-
- else -> unsupported
- }
-
- targetList.add(it)
- }
-
- BundleInfo(source.name, source.uid, bundle.patches, supported, unsupported, universal)
- }
- }
+ val allowExperimental = get().allowExperimental.getBlocking()
+ val bundlesFlow =
+ get().bundleInfoFlow(packageName, input.app.version)
init {
viewModelScope.launch {
@@ -88,63 +64,28 @@ class PatchesSelectorViewModel(
return@launch
}
- val experimental = allowExperimental.get()
- fun BundleInfo.hasDefaultPatches(): Boolean {
- return if (experimental) {
- all.asSequence()
- } else {
- sequence {
- yieldAll(supported)
- yieldAll(universal)
- }
- }.any { it.include }
- }
+ fun BundleInfo.hasDefaultPatches() = patchSequence(allowExperimental).any { it.include }
// Don't show the warning if there are no default patches.
selectionWarningEnabled = bundlesFlow.first().any(BundleInfo::hasDefaultPatches)
}
}
- var baseSelectionMode by mutableStateOf(BaseSelectionMode.DEFAULT)
- private set
-
- private val previousPatchesSelection: SnapshotStateMap> = mutableStateMapOf()
-
- init {
- viewModelScope.launch(Dispatchers.Default) { loadPreviousSelection() }
- }
-
- val hasPreviousSelection by derivedStateOf {
- previousPatchesSelection.filterValues(Set::isNotEmpty).isNotEmpty()
- }
-
private var hasModifiedSelection = false
+ private var customPatchesSelection: PersistentPatchesSelection? by savedStateHandle.saveable(
+ key = "selection",
+ stateSaver = patchesSaver,
+ ) {
+ mutableStateOf(input.currentSelection?.toPersistentPatchesSelection())
+ }
- private val explicitPatchesSelection: SnapshotExplicitPatchesSelection by savedStateHandle.saveable(
- saver = explicitPatchesSelectionSaver,
- init = ::mutableStateMapOf
- )
-
- private val patchOptions: SnapshotOptions by savedStateHandle.saveable(
+ private val patchOptions: PersistentOptions by savedStateHandle.saveable(
saver = optionsSaver,
- init = ::mutableStateMapOf
- )
-
- private val selectors by derivedStateOf> {
- arrayOf(
- // Patches that were explicitly selected
- { bundle, patch ->
- explicitPatchesSelection[bundle]?.get(patch.name)
- },
- // The fallback selection.
- when (baseSelectionMode) {
- BaseSelectionMode.DEFAULT -> ({ _, patch -> patch.include })
-
- BaseSelectionMode.PREVIOUS -> ({ bundle, patch ->
- previousPatchesSelection[bundle]?.contains(patch.name) ?: false
- })
- }
- )
+ ) {
+ // Convert Options to PersistentOptions
+ input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) ->
+ allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap()
+ }
}
/**
@@ -154,38 +95,39 @@ class PatchesSelectorViewModel(
val compatibleVersions = mutableStateListOf()
- var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED)
+ var filter by mutableIntStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED)
private set
- private suspend fun loadPreviousSelection() {
- val selection = (input.patchesSelection ?: selectionRepository.getSelection(
- packageName
- )).mapValues { (_, value) -> value.toSet() }
+ private suspend fun generateDefaultSelection(): PersistentPatchesSelection {
+ val bundles = bundlesFlow.first()
+ val generatedSelection =
+ bundles.toPatchSelection(allowExperimental) { _, patch -> patch.include }
- withContext(Dispatchers.Main) {
- previousPatchesSelection.putAll(selection)
+ return generatedSelection.toPersistentPatchesSelection()
+ }
+
+ fun selectionIsValid(bundles: List) = bundles.any { bundle ->
+ bundle.patchSequence(allowExperimental).any { patch ->
+ isSelected(bundle.uid, patch)
}
}
- fun switchBaseSelectionMode() = viewModelScope.launch {
- baseSelectionMode = if (baseSelectionMode == BaseSelectionMode.DEFAULT) {
- BaseSelectionMode.PREVIOUS
- } else {
- BaseSelectionMode.DEFAULT
- }
- }
-
- private fun getOrCreateSelection(bundle: Int) =
- explicitPatchesSelection.getOrPut(bundle, ::mutableStateMapOf)
-
- fun isSelected(bundle: Int, patch: PatchInfo) =
- selectors.firstNotNullOf { fn -> fn(bundle, patch) }
-
- fun togglePatch(bundle: Int, patch: PatchInfo) {
- val patches = getOrCreateSelection(bundle)
+ fun isSelected(bundle: Int, patch: PatchInfo) = customPatchesSelection?.let { selection ->
+ selection[bundle]?.contains(patch.name) ?: false
+ } ?: patch.include
+ fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {
hasModifiedSelection = true
- patches[patch.name] = !isSelected(bundle, patch)
+
+ val selection = customPatchesSelection ?: generateDefaultSelection()
+ val newPatches = selection[bundle]?.let { patches ->
+ if (patch.name in patches)
+ patches.remove(patch.name)
+ else
+ patches.add(patch.name)
+ } ?: persistentSetOf(patch.name)
+
+ customPatchesSelection = selection.put(bundle, newPatches)
}
fun confirmSelectionWarning(dismissPermanently: Boolean) {
@@ -207,46 +149,34 @@ class PatchesSelectorViewModel(
fun reset() {
patchOptions.clear()
- baseSelectionMode = BaseSelectionMode.DEFAULT
- explicitPatchesSelection.clear()
+ customPatchesSelection = null
hasModifiedSelection = false
app.toast(app.getString(R.string.patch_selection_reset_toast))
}
- suspend fun getSelection(): PatchesSelection {
- val bundles = bundlesFlow.first()
- val removeUnsupported = !allowExperimental.get()
+ fun getCustomSelection(): PatchesSelection? {
+ // Convert persistent collections to standard hash collections because persistent collections are not parcelable.
- return bundles.associate { bundle ->
- val included =
- bundle.all.filter { isSelected(bundle.uid, it) }.map { it.name }.toMutableSet()
-
- if (removeUnsupported) {
- val unsupported = bundle.unsupported.map { it.name }.toSet()
- included.removeAll(unsupported)
- }
-
- bundle.uid to included
- }
+ return customPatchesSelection?.mapValues { (_, v) -> v.toSet() }
}
- suspend fun saveSelection(selection: PatchesSelection) =
- viewModelScope.launch(Dispatchers.Default) {
- when {
- hasModifiedSelection -> selectionRepository.updateSelection(packageName, selection)
- baseSelectionMode == BaseSelectionMode.DEFAULT -> selectionRepository.clearSelection(
- packageName
- )
+ fun getOptions(): Options {
+ // Convert the collection for the same reasons as in getCustomSelection()
- else -> {}
- }
- }
+ return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } }
+ }
- fun getOptions(): Options = patchOptions
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
fun setOption(bundle: Int, patch: PatchInfo, key: String, value: Any?) {
- patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value
+ // All patches
+ val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
+ // The key-value options of an individual patch
+ val patchToOpts = patchesToOpts
+ .getOrElse(patch.name, ::persistentMapOf)
+ .put(key, value)
+
+ patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
}
fun resetOptions(bundle: Int, patch: PatchInfo) {
@@ -260,7 +190,7 @@ class PatchesSelectorViewModel(
fun openUnsupportedDialog(unsupportedPatches: List) {
compatibleVersions.addAll(unsupportedPatches.flatMap { patch ->
- patch.compatiblePackages?.find { it.packageName == input.selectedApp.packageName }?.versions.orEmpty()
+ patch.compatiblePackages?.find { it.packageName == packageName }?.versions.orEmpty()
})
}
@@ -273,50 +203,28 @@ class PatchesSelectorViewModel(
const val SHOW_UNIVERSAL = 2 // 2^1
const val SHOW_UNSUPPORTED = 4 // 2^2
- private fun SnapshotStateMap>.getOrCreate(key: K) =
- getOrPut(key, ::mutableStateMapOf)
-
- private val optionsSaver: Saver = snapshotStateMapSaver(
+ private val optionsSaver: Saver = snapshotStateMapSaver(
// Patch name -> Options
- valueSaver = snapshotStateMapSaver(
+ valueSaver = persistentMapSaver(
// Option key -> Option value
- valueSaver = snapshotStateMapSaver()
+ valueSaver = persistentMapSaver()
)
)
- private val explicitPatchesSelectionSaver: Saver =
- snapshotStateMapSaver(valueSaver = snapshotStateMapSaver())
+ private val patchesSaver: Saver> =
+ nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver()))
}
- /**
- * An enum for controlling the behavior of the selector.
- */
- enum class BaseSelectionMode {
- /**
- * Selection is determined by the [PatchInfo.include] field.
- */
- DEFAULT,
-
- /**
- * Selection is determined by what the user selected previously.
- * Any patch that is not part of the previous selection will be deselected.
- */
- PREVIOUS
- }
-
- data class BundleInfo(
- val name: String,
- val uid: Int,
- val all: List,
- val supported: List,
- val unsupported: List,
- val universal: List
+ data class Params(
+ val app: SelectedApp,
+ val currentSelection: PatchesSelection?,
+ val options: Options,
)
}
-private typealias Selector = (Int, PatchInfo) -> Boolean?
-private typealias ExplicitPatchesSelection = Map>
+// Versions of other types, but utilizing persistent/observable collection types.
+private typealias PersistentOptions = SnapshotStateMap>>
+private typealias PersistentPatchesSelection = PersistentMap>
-// Versions of other types, but utilizing observable collection types instead.
-private typealias SnapshotOptions = SnapshotStateMap>>
-private typealias SnapshotExplicitPatchesSelection = SnapshotStateMap>
\ No newline at end of file
+private fun PatchesSelection.toPersistentPatchesSelection(): PersistentPatchesSelection =
+ mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap()
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
new file mode 100644
index 00000000..86fca7de
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
@@ -0,0 +1,186 @@
+package app.revanced.manager.ui.viewmodel
+
+import android.content.pm.PackageInfo
+import android.os.Parcelable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
+import androidx.lifecycle.viewmodel.compose.saveable
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.domain.repository.PatchBundleRepository
+import app.revanced.manager.domain.repository.PatchOptionsRepository
+import app.revanced.manager.domain.repository.PatchSelectionRepository
+import app.revanced.manager.ui.model.BundleInfo
+import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
+import app.revanced.manager.ui.model.SelectedApp
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PM
+import app.revanced.manager.util.PatchesSelection
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.parcelize.Parcelize
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+
+@OptIn(SavedStateHandleSaveableApi::class)
+class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
+ val bundlesRepo: PatchBundleRepository = get()
+ private val selectionRepository: PatchSelectionRepository = get()
+ private val optionsRepository: PatchOptionsRepository = get()
+ private val pm: PM = get()
+ private val savedStateHandle: SavedStateHandle = get()
+ val prefs: PreferencesManager = get()
+
+ private val persistConfiguration = input.patches == null
+
+ private var _selectedApp by savedStateHandle.saveable {
+ mutableStateOf(input.app)
+ }
+
+ var selectedApp
+ get() = _selectedApp
+ set(value) {
+ _selectedApp = value
+ invalidateSelectedAppInfo()
+ }
+
+ var selectedAppInfo: PackageInfo? by mutableStateOf(null)
+
+ init {
+ invalidateSelectedAppInfo()
+ }
+
+ var options: Options by savedStateHandle.saveable {
+ val state = mutableStateOf(emptyMap())
+
+ viewModelScope.launch {
+ if (!persistConfiguration) return@launch // TODO: save options for patched apps.
+
+ val packageName = selectedApp.packageName // Accessing this from another thread may cause crashes.
+ state.value = withContext(Dispatchers.Default) { optionsRepository.getOptions(packageName) }
+ }
+
+ state
+ }
+ private set
+
+ private var selectionState by savedStateHandle.saveable {
+ if (input.patches != null) {
+ return@saveable mutableStateOf(SelectionState.Customized(input.patches))
+ }
+
+ val selection: MutableState = mutableStateOf(SelectionState.Default)
+
+ // Get previous selection (if present).
+ viewModelScope.launch {
+ val previous = selectionRepository.getSelection(selectedApp.packageName)
+
+ if (previous.values.sumOf { it.size } == 0) {
+ return@launch
+ }
+
+ selection.value = SelectionState.Customized(previous)
+ }
+
+ selection
+ }
+
+ private fun invalidateSelectedAppInfo() = viewModelScope.launch {
+ val info = when (val app = selectedApp) {
+ is SelectedApp.Download -> null
+ is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
+ is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
+ }
+
+ selectedAppInfo = info
+ }
+
+ fun getOptionsFiltered(bundles: List) = options.filtered(bundles)
+
+ fun getPatches(bundles: List, allowUnsupported: Boolean) =
+ selectionState.patches(bundles, allowUnsupported)
+
+ fun getCustomPatches(
+ bundles: List,
+ allowUnsupported: Boolean
+ ): PatchesSelection? =
+ (selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported)
+
+ fun updateConfiguration(
+ selection: PatchesSelection?,
+ options: Options,
+ bundles: List
+ ) {
+ selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default
+
+ val filteredOptions = options.filtered(bundles)
+ this.options = filteredOptions
+
+ if (!persistConfiguration) return
+
+ val packageName = selectedApp.packageName
+ viewModelScope.launch(Dispatchers.Default) {
+ selection?.let { selectionRepository.updateSelection(packageName, it) }
+ ?: selectionRepository.clearSelection(packageName)
+
+ optionsRepository.saveOptions(packageName, filteredOptions)
+ }
+ }
+
+ data class Params(
+ val app: SelectedApp,
+ val patches: PatchesSelection?,
+ )
+
+ private companion object {
+ /**
+ * Returns a copy with all nonexistent options removed.
+ */
+ private fun Options.filtered(bundles: List): Options = buildMap options@{
+ bundles.forEach bundles@{ bundle ->
+ val bundleOptions = this@filtered[bundle.uid] ?: return@bundles
+
+ val patches = bundle.all.associateBy { it.name }
+
+ this@options[bundle.uid] = buildMap bundleOptions@{
+ bundleOptions.forEach patch@{ (patchName, values) ->
+ // Get all valid option keys for the patch.
+ val validOptionKeys =
+ patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch
+
+ this@bundleOptions[patchName] = values.filterKeys { key ->
+ key in validOptionKeys
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private sealed interface SelectionState : Parcelable {
+ fun patches(bundles: List, allowUnsupported: Boolean): PatchesSelection
+
+ @Parcelize
+ data class Customized(val patchesSelection: PatchesSelection) : SelectionState {
+ override fun patches(bundles: List, allowUnsupported: Boolean) =
+ bundles.toPatchSelection(
+ allowUnsupported
+ ) { uid, patch ->
+ patchesSelection[uid]?.contains(patch.name) ?: false
+ }
+ }
+
+ @Parcelize
+ data object Default : SelectionState {
+ override fun patches(bundles: List, allowUnsupported: Boolean) =
+ bundles.toPatchSelection(allowUnsupported) { _, patch -> patch.include }
+ }
+}
+
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt
deleted file mode 100644
index 2ee6ed33..00000000
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package app.revanced.manager.ui.viewmodel
-
-import android.app.Application
-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 app.revanced.manager.R
-import app.revanced.manager.network.api.ReVancedAPI
-import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
-import app.revanced.manager.network.service.HttpService
-import app.revanced.manager.network.utils.getOrThrow
-import app.revanced.manager.util.APK_MIMETYPE
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import app.revanced.manager.util.PM
-import app.revanced.manager.util.uiSafe
-import io.ktor.client.plugins.onDownload
-import io.ktor.client.request.url
-import kotlinx.coroutines.withContext
-import java.io.File
-
-class UpdateProgressViewModel(
- app: Application,
- private val reVancedAPI: ReVancedAPI,
- private val http: HttpService,
- private val pm: PM
-) : ViewModel() {
- var downloadedSize by mutableStateOf(0L)
- private set
- var totalSize by mutableStateOf(0L)
- private set
- val downloadProgress by derivedStateOf {
- if (downloadedSize == 0L || totalSize == 0L) return@derivedStateOf 0f
-
- downloadedSize.toFloat() / totalSize.toFloat()
- }
- val isInstalling by derivedStateOf { downloadProgress >= 1 }
- var finished by mutableStateOf(false)
- private set
-
- private val location = File.createTempFile("updater", ".apk", app.cacheDir)
- private val job = viewModelScope.launch {
- uiSafe(app, R.string.download_manager_failed, "Failed to download manager") {
- withContext(Dispatchers.IO) {
- val asset = reVancedAPI
- .getRelease("revanced-manager")
- .getOrThrow()
- .findAssetByType(APK_MIMETYPE)
-
- http.download(location) {
- url(asset.downloadUrl)
- onDownload { bytesSentTotal, contentLength ->
- downloadedSize = bytesSentTotal
- totalSize = contentLength
- }
- }
- }
- finished = true
- }
- }
-
- fun installUpdate() = viewModelScope.launch {
- pm.installApp(listOf(location))
- }
-
- override fun onCleared() {
- super.onCleared()
-
- job.cancel()
- location.delete()
- }
-}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt
new file mode 100644
index 00000000..d8b26b22
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt
@@ -0,0 +1,170 @@
+package app.revanced.manager.ui.viewmodel
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInstaller
+import android.util.Log
+import androidx.annotation.StringRes
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.revanced.manager.R
+import app.revanced.manager.data.platform.NetworkInfo
+import app.revanced.manager.network.api.ReVancedAPI
+import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
+import app.revanced.manager.network.dto.ReVancedRelease
+import app.revanced.manager.network.service.HttpService
+import app.revanced.manager.network.utils.getOrThrow
+import app.revanced.manager.service.InstallService
+import app.revanced.manager.service.UninstallService
+import app.revanced.manager.util.APK_MIMETYPE
+import app.revanced.manager.util.PM
+import app.revanced.manager.util.simpleMessage
+import app.revanced.manager.util.tag
+import app.revanced.manager.util.toast
+import app.revanced.manager.util.uiSafe
+import io.ktor.client.plugins.onDownload
+import io.ktor.client.request.url
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.io.File
+
+class UpdateViewModel(
+ private val downloadOnScreenEntry: Boolean
+) : ViewModel(), KoinComponent {
+ private val app: Application by inject()
+ private val reVancedAPI: ReVancedAPI by inject()
+ private val http: HttpService by inject()
+ private val pm: PM by inject()
+ private val networkInfo: NetworkInfo by inject()
+
+ var downloadedSize by mutableStateOf(0L)
+ private set
+ var totalSize by mutableStateOf(0L)
+ private set
+ val downloadProgress by derivedStateOf {
+ if (downloadedSize == 0L || totalSize == 0L) return@derivedStateOf 0f
+
+ downloadedSize.toFloat() / totalSize.toFloat()
+ }
+ var showInternetCheckDialog by mutableStateOf(false)
+ var state by mutableStateOf(State.CAN_DOWNLOAD)
+ private set
+
+ var installError by mutableStateOf("")
+
+ var changelog: Changelog? by mutableStateOf(null)
+
+ private val location = File.createTempFile("updater", ".apk", app.cacheDir)
+ private var release: ReVancedRelease? = null
+ private val job = viewModelScope.launch {
+ uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
+ withContext(Dispatchers.IO) {
+ val response = reVancedAPI
+ .getLatestRelease("revanced-manager")
+ .getOrThrow()
+ release = response
+ changelog = Changelog(
+ response.metadata.tag,
+ response.findAssetByType(APK_MIMETYPE).downloadCount,
+ response.metadata.publishedAt,
+ response.metadata.body
+ )
+ }
+ if (downloadOnScreenEntry) {
+ downloadUpdate()
+ } else {
+ state = State.CAN_DOWNLOAD
+ }
+ }
+ }
+
+ fun downloadUpdate(ignoreInternetCheck: Boolean = false) = viewModelScope.launch {
+ uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {
+ withContext(Dispatchers.IO) {
+ if (!networkInfo.isSafe() && !ignoreInternetCheck) {
+ showInternetCheckDialog = true
+ } else {
+ state = State.DOWNLOADING
+ val asset = release?.findAssetByType(APK_MIMETYPE)
+ ?: throw Exception("couldn't find asset to download")
+
+ http.download(location) {
+ url(asset.downloadUrl)
+ onDownload { bytesSentTotal, contentLength ->
+ downloadedSize = bytesSentTotal
+ totalSize = contentLength
+ }
+ }
+ state = State.CAN_INSTALL
+ }
+ }
+ }
+ }
+
+ fun installUpdate() = viewModelScope.launch {
+ state = State.INSTALLING
+
+ pm.installApp(listOf(location))
+ }
+
+ private val installBroadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ intent?.let {
+ val pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999)
+ val extra =
+ intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
+
+ if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
+ app.toast(app.getString(R.string.install_app_success))
+ state = State.SUCCESS
+ } else {
+ state = State.FAILED
+ // TODO: handle install fail with a popup
+ installError = extra
+ app.toast(app.getString(R.string.install_app_fail, extra))
+ }
+ }
+ }
+ }
+
+ init {
+ ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply {
+ addAction(InstallService.APP_INSTALL_ACTION)
+ }, ContextCompat.RECEIVER_NOT_EXPORTED)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ app.unregisterReceiver(installBroadcastReceiver)
+
+ job.cancel()
+ location.delete()
+ }
+
+ data class Changelog(
+ val version: String,
+ val downloadCount: Int,
+ val publishDate: String,
+ val body: String,
+ )
+
+ enum class State(@StringRes val title: Int, val showCancel: Boolean = false) {
+ CAN_DOWNLOAD(R.string.update_available),
+ DOWNLOADING(R.string.downloading_manager_update, true),
+ CAN_INSTALL(R.string.ready_to_install_update, true),
+ INSTALLING(R.string.installing_manager_update),
+ FAILED(R.string.install_update_manager_failed),
+ SUCCESS(R.string.update_completed)
+ }
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt
index f7420131..cae25116 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt
@@ -68,7 +68,7 @@ class VersionSelectorViewModel(
}
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
- downloadedApps.filter { it.packageName == packageName }.map { SelectedApp.Local(it.packageName, it.version, it.file, false) }
+ downloadedApps.filter { it.packageName == packageName }.map { SelectedApp.Local(it.packageName, it.version, downloadedAppRepository.getApkFileForApp(it), false) }
}
init {
diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt
index 45f21377..12a91a6c 100644
--- a/app/src/main/java/app/revanced/manager/util/PM.kt
+++ b/app/src/main/java/app/revanced/manager/util/PM.kt
@@ -98,7 +98,18 @@ class PM(
null
}
- fun getPackageInfo(file: File): PackageInfo? = app.packageManager.getPackageArchiveInfo(file.absolutePath, 0)
+ fun getPackageInfo(file: File): PackageInfo? {
+ val path = file.absolutePath
+ val pkgInfo = app.packageManager.getPackageArchiveInfo(path, 0) ?: return null
+
+ // This is needed in order to load label and icon.
+ pkgInfo.applicationInfo.apply {
+ sourceDir = path
+ publicSourceDir = path
+ }
+
+ return pkgInfo
+ }
fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString()
diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt
index e8f38056..760d396a 100644
--- a/app/src/main/java/app/revanced/manager/util/Util.kt
+++ b/app/src/main/java/app/revanced/manager/util/Util.kt
@@ -2,6 +2,12 @@ package app.revanced.manager.util
import android.content.Context
import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.icu.number.Notation
+import android.icu.number.NumberFormatter
+import android.icu.number.Precision
+import android.icu.text.CompactDecimalFormat
+import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.annotation.StringRes
@@ -11,17 +17,28 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import app.revanced.manager.R
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
+import java.time.Duration
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeParseException
import java.util.Locale
typealias PatchesSelection = Map>
typealias Options = Map>>
+val Context.isDebuggable get() = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
+
fun Context.openUrl(url: String) {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
@@ -41,16 +58,21 @@ fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) {
* @param logMsg The log message.
* @param block The code to execute.
*/
+@OptIn(DelicateCoroutinesApi::class)
inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) {
try {
block()
} catch (error: Exception) {
- context.toast(
- context.getString(
- toastMsg,
- error.simpleMessage()
+ // You can only toast on the main thread.
+ GlobalScope.launch(Dispatchers.Main) {
+ context.toast(
+ context.getString(
+ toastMsg,
+ error.simpleMessage()
+ )
)
- )
+ }
+
Log.e(tag, logMsg, error)
}
}
@@ -97,4 +119,50 @@ suspend fun Flow>.collectEach(block: suspend (T) -> Unit) {
block(it)
}
}
+}
+
+fun Int.formatNumber(): String {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ NumberFormatter.with()
+ .notation(Notation.compactShort())
+ .decimal(NumberFormatter.DecimalSeparatorDisplay.ALWAYS)
+ .precision(Precision.fixedFraction(1))
+ .locale(Locale.getDefault())
+ .format(this)
+ .toString()
+ } else {
+ val compact = CompactDecimalFormat.getInstance(
+ Locale.getDefault(), CompactDecimalFormat.CompactStyle.SHORT
+ )
+ compact.maximumFractionDigits = 1
+ compact.format(this)
+ }
+}
+
+fun String.relativeTime(context: Context): String {
+ try {
+ val currentTime = ZonedDateTime.now(ZoneId.of("UTC"))
+ val inputDateTime = ZonedDateTime.parse(this)
+ val duration = Duration.between(inputDateTime, currentTime)
+
+ return when {
+ duration.toMinutes() < 1 -> context.getString(R.string.just_now)
+ duration.toMinutes() < 60 -> context.getString(R.string.minutes_ago, duration.toMinutes().toString())
+ duration.toHours() < 24 -> context.getString(R.string.hours_ago, duration.toHours().toString())
+ duration.toDays() < 30 -> context.getString(R.string.days_ago, duration.toDays().toString())
+ else -> {
+ val formatter = DateTimeFormatter.ofPattern("MMM d")
+ val formattedDate = inputDateTime.format(formatter)
+ if (inputDateTime.year != currentTime.year) {
+ val yearFormatter = DateTimeFormatter.ofPattern(", yyyy")
+ val formattedYear = inputDateTime.format(yearFormatter)
+ "$formattedDate$formattedYear"
+ } else {
+ formattedDate
+ }
+ }
+ }
+ } catch (e: DateTimeParseException) {
+ return context.getString(R.string.invalid_date)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt b/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt
new file mode 100644
index 00000000..a94a5388
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt
@@ -0,0 +1,24 @@
+package app.revanced.manager.util.saver
+
+import android.os.Parcelable
+import androidx.compose.runtime.saveable.Saver
+import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
+
+@Parcelize
+class Nullable(val inner: @RawValue T?) : Parcelable
+
+/**
+ * Creates a saver that can save nullable versions of types that have custom savers.
+ */
+fun nullableSaver(baseSaver: Saver): Saver> =
+ Saver(
+ save = { value ->
+ with(baseSaver) {
+ save(value ?: return@Saver Nullable(null))
+ }?.let(::Nullable)
+ },
+ restore = {
+ it.inner?.let(baseSaver::restore)
+ }
+ )
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt b/app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt
new file mode 100644
index 00000000..2a1418cd
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt
@@ -0,0 +1,69 @@
+package app.revanced.manager.util.saver
+
+import androidx.compose.runtime.saveable.Saver
+import kotlinx.collections.immutable.*
+
+/**
+ * Create a [Saver] for [PersistentList]s.
+ */
+fun persistentListSaver() = Saver, List>(
+ save = {
+ it.toList()
+ },
+ restore = {
+ it.toPersistentList()
+ }
+)
+
+/**
+ * Create a [Saver] for [PersistentSet]s.
+ */
+fun persistentSetSaver() = Saver, Set>(
+ save = {
+ it.toSet()
+ },
+ restore = {
+ it.toPersistentSet()
+ }
+)
+
+/**
+ * Create a [Saver] for [PersistentMap]s.
+ */
+fun persistentMapSaver() = Saver, Map>(
+ save = {
+ it.toMap()
+ },
+ restore = {
+ it.toPersistentMap()
+ }
+)
+
+/**
+ * Create a saver for [PersistentMap]s with a custom [Saver] used for the values.
+ * Null values will not be saved by this [Saver].
+ *
+ * @param valueSaver The [Saver] used for the values of the [Map].
+ */
+fun persistentMapSaver(
+ valueSaver: Saver
+) = Saver, Map>(
+ save = {
+ buildMap {
+ it.forEach { (key, value) ->
+ with(valueSaver) {
+ save(value)?.let {
+ this@buildMap[key] = it
+ }
+ }
+ }
+ }
+ },
+ restore = {
+ buildMap {
+ it.forEach { (key, value) ->
+ this[key] = valueSaver.restore(value) ?: return@forEach
+ }
+ }.toPersistentMap()
+ }
+)
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 58c4b940..e1cdfaf2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -11,6 +11,8 @@
Select an app
Select patches
+ Patching on ARMv7 devices is not yet supported and will most likely fail.
+
Import
Import patch bundle
Bundle patches
@@ -23,7 +25,19 @@
Missing
Error
-
+
+ %1s ⢠%2d available patches
+
+ Start patching the application
+ Patch selection and options
+ %d patches selected
+ No patches selected
+
+ Change version
+ %s selected
+
+ Could not import legacy settings
+
Select updates to receive
Periodically connect to update providers to check for updates.
ReVanced Manager
@@ -51,6 +65,8 @@
Adapt colors to the wallpaper
Theme
Choose between light or dark theme
+ Multi-threaded DEX file writer
+ Use multiple cores to write DEX files. This is faster, but uses more memory
Allow experimental patches
Allow patching incompatible patches with experimental versions, something may break
Import keystore
@@ -76,6 +92,13 @@
Failed to backup patches selection: %s
Clear patches selection
Clear all patches selection
+ Patch options
+ Clear patch options for package
+ Resets patch options for a single package
+ Clear patch options for bundle
+ Resets patch options for all patches in a bundle
+ Clear all patch options
+ Resets all patch options
Prefer split apks
Prefer split apks instead of full apks
Prefer universal apks
@@ -143,8 +166,6 @@
Unsupported app
Unsupported patches
Universal patches
- Use default selection
- Use previous selection
Patch selection and options has been reset to recommended defaults
Stop using defaults?
You may encounter issues when not using the default patch selection and options.
@@ -152,6 +173,7 @@
Supported
Universal
Unsupported
+ Patch name
Some of the patches do not support this app version (%1$s). The patches only support the following version(s): %2$s.
Continue with this version?
Not all patches support this version (%s). Do you want to continue anyway?
@@ -188,6 +210,9 @@
Downloadable versions
Already patched
+ Filter
+ Compatibility
+
Edit
More options
Value
@@ -206,8 +231,8 @@
Failed to install app: %s
Failed to uninstall app: %s
Open
- Export
- Apk exported
+ Save APK
+ APK Saved
Failed to sign Apk: %s
Save logs
Select installation type
@@ -219,6 +244,7 @@
Patching
Saving
Write patched Apk
+ Sign Apk
Patching in progressā¦
completed
@@ -230,6 +256,7 @@
More
Continue
+ Dismiss
Do not show this again
Donate
Website
@@ -248,6 +275,12 @@
Choose the type of bundle you want
About ReVanced Manager
ReVanced Manager is an application designed to work with ReVanced Patcher, which allows for long-lasting patches to be created for Android apps. The patching system is designed to automatically work with new versions of apps with minimal maintenance.
+ An update is available
+ Current version: %s
+ New version: %s
+ Ready to install update
+ Update installed
+ Failed to install update
A minor update for ReVanced Manager is available. Click here to update and get the latest features and fixes!
Update channel
Stable
@@ -257,7 +290,7 @@
Loading changelog
Failed to download changelog: %s
Check out the latest changes in this update
- Battery optimization must be turned off in order for ReVanced Manager to work correctly in the background. Tap here to turn off.
+ Battery optimization must be turned off in order for ReVanced Manager to work correctly in the background. Click here to turn off.
Installing updateā¦
Downloading updateā¦
Failed to download update: %s
@@ -265,4 +298,21 @@
Save
Update
Tap on Update when prompted. \n ReVanced Manager will close when updating.
+ No changelogs found
+ Just now
+ %sm ago
+ %sh ago
+ %sd ago
+ Invalid date
+ Disable battery optimization
+
+ Failed to check for updates
+ Not now
+ New update available
+ A new version (%s) is available for download.
+ Failed to download update: %s
+ Download
+ You are currently on a metered connection, and data charges from your service provider may apply.\n\nDo you still want to continue?
+ Download update?
+ No contributors found
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0c7b5e3b..aa2e6c44 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,24 +1,25 @@
[versions]
-ktx = "1.10.1"
+ktx = "1.12.0"
viewmodel-lifecycle = "2.6.2"
splash-screen = "1.0.1"
-compose-activity = "1.7.2"
+compose-activity = "1.8.0"
paging = "3.2.1"
preferences-datastore = "1.0.0"
work-runtime = "2.8.1"
-compose-bom = "2023.06.01"
+compose-bom = "2023.10.00"
accompanist = "0.30.1"
serialization = "1.6.0"
collection = "0.3.5"
room-version = "2.5.2"
-revanced-patcher = "16.0.1"
-revanced-library = "1.1.1"
+revanced-patcher = "19.1.0"
+revanced-library = "1.4.0"
koin-version = "3.4.3"
koin-version-compose = "3.4.6"
-reimagined-navigation = "1.4.0"
+reimagined-navigation = "1.5.0"
ktor = "2.3.3"
-markdown = "0.5.0"
-androidGradlePlugin = "8.1.1"
+markdown-renderer = "0.8.0"
+fading-edges = "1.0.4"
+androidGradlePlugin = "8.1.2"
kotlinGradlePlugin = "1.9.10"
devToolsGradlePlugin = "1.9.10-1.0.13"
aboutLibrariesGradlePlugin = "10.8.3"
@@ -43,7 +44,7 @@ compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref =
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.2.0-alpha10"}
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Coil
@@ -93,7 +94,10 @@ skrapeit-dsl = { group = "it.skrape", name = "skrapeit-dsl", version.ref = "skra
skrapeit-parser = { group = "it.skrape", name = "skrapeit-html-parser", version.ref = "skrapeit" }
# Markdown
-markdown = { group = "org.jetbrains", name = "markdown", version.ref = "markdown" }
+markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdown-renderer" }
+
+# Fading Edges
+fading-edges = { group = "com.github.GIGAMOLE", name = "ComposeFadingEdges", version.ref = "fading-edges"}
# LibSU
libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 7f93135c..d64cd491 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 864d6c47..db8c3baa 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=591855b517fc635b9e04de1d05d5e76ada3f89f5fc76f87978d1b245b4f69225
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
+distributionSha256Sum=9d926787066a081739e8200858338b4a69e837c3a821a33aca9db09dd4a41026
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 0adc8e1a..1aa94a42 100755
--- a/gradlew
+++ b/gradlew
@@ -145,7 +145,7 @@ 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=SC3045
+ # shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -153,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
+ # shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -202,11 +202,11 @@ 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 $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_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" \
diff --git a/settings.gradle.kts b/settings.gradle.kts
index fbde0be1..c4887269 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,59 +1,19 @@
pluginManagement {
repositories {
- // TODO: remove this once https://github.com/gradle/gradle/issues/23572 is fixed
- val (gprUser, gprKey) = if (File(".gradle/gradle.properties").exists()) {
- File(".gradle/gradle.properties").inputStream().use {
- java.util.Properties().apply { load(it) }.let {
- it.getProperty("gpr.user") to it.getProperty("gpr.key")
- }
- }
- } else {
- null to null
- }
-
- fun RepositoryHandler.githubPackages(name: String) = maven {
- url = uri(name)
- credentials {
- username = gprUser ?: providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
- password = gprKey ?: providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN")
- }
- }
-
gradlePluginPortal()
google()
mavenCentral()
maven("https://jitpack.io")
- githubPackages("https://maven.pkg.github.com/revanced/revanced-patcher")
- githubPackages("https://maven.pkg.github.com/revanced/revanced-library")
+ mavenLocal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
- // TODO: remove this once https://github.com/gradle/gradle/issues/23572 is fixed
- val (gprUser, gprKey) = if (File(".gradle/gradle.properties").exists()) {
- File(".gradle/gradle.properties").inputStream().use {
- java.util.Properties().apply { load(it) }.let {
- it.getProperty("gpr.user") to it.getProperty("gpr.key")
- }
- }
- } else {
- null to null
- }
-
- fun RepositoryHandler.githubPackages(name: String) = maven {
- url = uri(name)
- credentials {
- username = gprUser ?: providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
- password = gprKey ?: providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN")
- }
- }
-
google()
mavenCentral()
maven("https://jitpack.io")
- githubPackages("https://maven.pkg.github.com/revanced/revanced-patcher")
- githubPackages("https://maven.pkg.github.com/revanced/revanced-library")
+ mavenLocal()
}
}
rootProject.name = "ReVanced Manager"